[Icons] Keep semantic red and green on themed glyphs

The one-glyph cutover recolored every icon from a single palette role, so a
disabled deck, a not-ready player and a remove button all lost the red or green
that carried their meaning.

Add Positive and Negative application colors beside the accents, ship
scheme-appropriate defaults for every theme, and resolve them through
ThemeManager. Icons now take an IconTint: a palette role for structural glyphs,
or a semantic role for states and add/remove actions. A disabled semantic icon
falls back to the disabled foreground; on a selected row a semantic tint keeps
its color only while it stays legible on the highlight, and a palette tint keeps
the existing WindowText -> HighlightedText swap.
This commit is contained in:
Lukas Brübach 2026-11-04 17:32:36 +01:00 • committed by BruebachL
parent df74917cb0
commit db4e07e55d
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));
}
QPushButton *ok = new QPushButton(loadThemedIcon(QStringLiteral("icons/increment")), QString());
QPushButton *ok = new QPushButton(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive), QString());
ok->setObjectName("ok");
ok->setMaximumSize(20, 20);

View file

@ -68,8 +68,8 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor,
QWidget *parent)
: QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false)
{
readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start"));
notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start"));
readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive);
notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start"), AppColor::Negative);
concededIcon = loadThemedIcon(QStringLiteral("icons/conceded"));
playerIcon = loadThemedIcon(QStringLiteral("icons/player"));
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.
cfg.appColors[AppColor::AccentStrong] = hl;
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
QColor bright;

View file

@ -6,6 +6,7 @@
#include <QApplication>
#include <QColor>
#include <QCoreApplication>
#include <QEvent>
#include <QFont>
#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 = {
{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::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)
@ -179,7 +184,7 @@ void PaletteGridWidget::buildGrid(QWidget *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);
grid->addWidget(label, row, 0);

View file

@ -9,6 +9,7 @@
#include <QList>
#include <QMetaEnum>
#include <QPair>
#include <QStringLiteral>
#include <QTextStream>
#include <qnamespace.h>
#include <utility>
@ -451,3 +452,19 @@ FontConfig FontConfig::fromThemeDir(const QString &themeDirPath)
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,
AccentSoft,
Positive,
Negative,
};
Q_ENUM_NS(Role)
} // namespace AppColor
@ -41,6 +43,16 @@ enum Role
Q_ENUM_NS(Role)
} // 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
{
QString colorScheme;

View file

@ -23,19 +23,74 @@
#include <QStyle>
#include <QSvgRenderer>
#include <QtGlobal>
#include <cmath>
#include <utility>
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::ColorRole effective =
mode == QIcon::Selected && role == QPalette::WindowText ? QPalette::HighlightedText : role;
return qApp->palette().color(group, effective).name(QColor::HexRgb).toLatin1();
mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole;
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
{
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) {
// A theme switch changes the resolved asset (scheme-qualified
@ -120,7 +175,7 @@ public:
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
@ -238,7 +293,7 @@ private:
// 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.
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 auto cached = cache.constFind(key);
if (cached != cache.constEnd()) {
@ -266,7 +321,7 @@ private:
}
QString prefix;
QPalette::ColorRole role;
IconTint tint;
mutable QHash<QString, QPixmap> cache;
mutable QString resolvedPath;
mutable QString resolvedThemePath;
@ -279,12 +334,12 @@ private:
};
} // 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)) {
return {};
@ -295,6 +350,6 @@ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole r
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);
}

View file

@ -6,6 +6,9 @@
#ifndef THEME_ICON_H
#define THEME_ICON_H
#include "theme_config.h"
#include <QColor>
#include <QIcon>
#include <QPalette>
#include <QPixmap>
@ -13,6 +16,41 @@
#include <QString>
#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.
*
@ -24,11 +62,11 @@
*
* @param prefix Asset prefix relative to the `theme:` search path, with no file
* extension (for example `icons/search`), matching themePixmap().
* @param role Palette role whose color replaces the sentinel. Selected glyphs
* use HighlightedText unless a role other than WindowText is given.
* @param tint Color that replaces the sentinel: a palette role or a semantic
* application color. Defaults to WindowText.
* @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.
@ -38,13 +76,12 @@ QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::Wi
*
* @param resolvedPath Asset path relative to the `theme:` search path, as
* 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.
* @return The recolored pixmap, or a null pixmap when the asset is not a
* sentinel-carrying SVG.
*/
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath,
QPalette::ColorRole role = QPalette::WindowText,
const QSize &size = {});
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint = {}, const QSize &size = {});
#endif // THEME_ICON_H

View file

@ -639,7 +639,10 @@ QColor ThemeManager::appColor(AppColor::Role role) const
// unconditionally keeps every Qt version rendering identically.
const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight);
if (role == AppColor::AccentSoft) {
switch (role) {
case AppColor::AccentStrong:
return accent;
case AppColor::AccentSoft: {
constexpr int SOFT_SATURATION_PERCENT = 70;
constexpr int SOFT_LIGHTNESS_OFFSET = 60;
@ -648,6 +651,12 @@ QColor ThemeManager::appColor(AppColor::Role role) const
qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 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;
}

View file

@ -147,7 +147,8 @@ public:
void previewFonts(const FontConfig &cfg);
// 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;
/** @brief Resolves an application font role: the theme's stored [Fonts]
* value when present, otherwise the application font. Roles that

View file

@ -83,12 +83,14 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent
&DeckEditorDatabaseDisplayWidget::onRelatedCardClicked);
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);
auto *tbAddCard = new QToolButton(this);
tbAddCard->setDefaultAction(aAddCard);
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")));
connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard);
auto *tbAddCardToSideboard = new QToolButton(this);

View file

@ -248,19 +248,19 @@ void DeckEditorDeckDockWidget::createDeckDock()
&DeckEditorDeckDockWidget::applyActiveGroupCriteria);
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);
auto *tbIncrement = new QToolButton(this);
tbIncrement->setDefaultAction(aIncrement);
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);
auto *tbDecrement = new QToolButton(this);
tbDecrement->setDefaultAction(aDecrement);
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);
auto *tbRemoveCard = new QToolButton(this);
tbRemoveCard->setDefaultAction(aRemoveCard);

View file

@ -64,7 +64,7 @@ void DeckEditorFilterDockWidget::createFiltersDock()
connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter);
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);
aClearFilterAll = new QAction(QString(), this);

View file

@ -38,7 +38,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
previousHosts = new QComboBox(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->setFixedWidth(30);

View file

@ -108,10 +108,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul
&DlgEditTokens::tokenSelectionChanged);
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);
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);
auto *databaseToolBar = new QToolBar;

View file

@ -34,7 +34,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
previousHosts = new QComboBox(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->setFixedWidth(30);

View file

@ -80,7 +80,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
}
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);
aEdit = new QAction(this);
@ -88,7 +88,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL);
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);
aRateLimit = new QAction(this);

View file

@ -146,7 +146,7 @@ MessagesSettingsPage::MessagesSettingsPage()
}
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);
aEdit = new QAction(this);
@ -154,7 +154,7 @@ MessagesSettingsPage::MessagesSettingsPage()
connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit);
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);
auto *messageToolBar = new QToolBar;

View file

@ -199,7 +199,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder);
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);
aOpenDecksFolder = new QAction(this);
@ -217,7 +217,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder);
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);
aShareDecks = new QAction(this);

View file

@ -155,7 +155,7 @@ QGroupBox *TabReplays::createLeftLayout()
aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder);
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);
aOpenReplaysFolder = new QAction(this);
@ -217,7 +217,7 @@ QGroupBox *TabReplays::createRightLayout()
aKeep->setIcon(loadThemedIcon(QStringLiteral("icons/lock")));
connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay);
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);
aGetReplayCode = new QAction(this);
aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share")));

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,10 @@
# 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
# overrides. Only the identity app colors are provided here, so buttons keep
# their classic greens while the actual palette stays native to the OS.
# overrides. Only the identity and semantic app colors are provided here, so
# buttons keep their classic greens while the actual palette stays native to
# the OS.
[AppColors]
AccentStrong = #ff148c3c
AccentSoft = #ff78c850
Positive = #ff6dd58c
Negative = #ffff8a80

View file

@ -1,7 +1,10 @@
# 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
# overrides. Only the identity app colors are provided here, so buttons keep
# their classic greens while the actual palette stays native to the OS.
# overrides. Only the identity and semantic app colors are provided here, so
# buttons keep their classic greens while the actual palette stays native to
# the OS.
[AppColors]
AccentStrong = #ff148c3c
AccentSoft = #ff78c850
Positive = #ff1b7f3b
Negative = #ffb3261e

View file

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

View file

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

View file

@ -187,3 +187,86 @@ TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved)
ASSERT_FALSE(pixmap.isNull());
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"));
}
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
// the fallback then links the bare gtest library without gtest_main.
int main(int argc, char **argv)