From db4e07e55d480ccc19f1b91e51c9cf3e87fa2e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 4 Nov 2026 17:32:36 +0100 Subject: [PATCH] [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. --- cockatrice/src/filters/filter_builder.cpp | 2 +- .../player/player_list_widget.cpp | 4 +- .../palette_editor/palette_generator.cpp | 4 + .../palette_editor/palette_grid_widget.cpp | 7 +- cockatrice/src/interface/theme_config.cpp | 17 ++++ cockatrice/src/interface/theme_config.h | 12 +++ cockatrice/src/interface/theme_icon.cpp | 79 +++++++++++++++--- cockatrice/src/interface/theme_icon.h | 51 ++++++++++-- cockatrice/src/interface/theme_manager.cpp | 23 +++-- cockatrice/src/interface/theme_manager.h | 3 +- .../deck_editor_database_display_widget.cpp | 4 +- .../deck_editor_deck_dock_widget.cpp | 6 +- .../deck_editor_filter_dock_widget.cpp | 2 +- .../interface/widgets/dialogs/dlg_connect.cpp | 2 +- .../widgets/dialogs/dlg_edit_tokens.cpp | 4 +- .../widgets/dialogs/dlg_register.cpp | 2 +- .../deck_editor_settings_page.cpp | 4 +- .../settings_page/messages_settings_page.cpp | 4 +- .../widgets/tabs/tab_deck_storage.cpp | 4 +- .../interface/widgets/tabs/tab_replays.cpp | 4 +- .../visual_database_display_widget.cpp | 2 +- .../themes/Fabric/palette-default-dark.toml | 2 + .../themes/Fabric/palette-default-light.toml | 2 + .../themes/Fusion/palette-default-dark.toml | 2 + .../themes/Fusion/palette-default-light.toml | 2 + .../themes/Leather/palette-default-dark.toml | 2 + .../themes/Leather/palette-default-light.toml | 2 + .../themes/Plasma/palette-default-dark.toml | 2 + .../themes/Plasma/palette-default-light.toml | 2 + .../themes/System/palette-default-dark.toml | 7 +- .../themes/System/palette-default-light.toml | 7 +- .../VelvetMarble/palette-default-dark.toml | 2 + .../VelvetMarble/palette-default-light.toml | 2 + .../theme_icon_render_test.cpp | 83 +++++++++++++++++++ .../theme_migration/theme_migration_test.cpp | 35 ++++++++ 35 files changed, 339 insertions(+), 53 deletions(-) diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 9c2a91a5b..303f3e3f6 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -30,7 +30,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) typeCombo->addItem(CardFilter::typeName(static_cast(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); diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 6895e7db8..f73471bdc 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp @@ -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")); diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index c98dcbe03..6bcfdc713 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -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; diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 392f01d0d..54e9024fd 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -62,6 +63,10 @@ static const QMap ROLE_DESCRIPTIONS = { static const QMap 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); diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 54143d7d3..42d7a4691 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -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 {}; +} diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 84601a2bf..6f68fa7cb 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -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; diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index 161c436ca..1739c2b43 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -23,19 +23,74 @@ #include #include #include +#include #include 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(mode)); + resolvedHasSentinel ? sentinelColor(mode, tint) : QByteArray::number(static_cast(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 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); } diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h index e26015ea1..767aa0321 100644 --- a/cockatrice/src/interface/theme_icon.h +++ b/cockatrice/src/interface/theme_icon.h @@ -6,6 +6,9 @@ #ifndef THEME_ICON_H #define THEME_ICON_H +#include "theme_config.h" + +#include #include #include #include @@ -13,6 +16,41 @@ #include #include +/** + * @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 diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index f120af8e4..1cf62ae8b 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -639,14 +639,23 @@ 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) { - constexpr int SOFT_SATURATION_PERCENT = 70; - constexpr int SOFT_LIGHTNESS_OFFSET = 60; + switch (role) { + case AppColor::AccentStrong: + 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 - 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)); + // 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)); + } + 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; diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index ffe1043b5..3bcfd7b3c 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -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 diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index f25e6c0ff..128597962 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index 47d2a1aae..f35126753 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index 8122d1847..9621538e1 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index daee5f1f9..b698404df 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index b06481c4b..d086404f8 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -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; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index 09afee32d..1f7bb5d25 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index e90603858..330830efb 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index 09dd9e9b0..f01dd2161 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -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; diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 8af9d5e6e..06b37fc8c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index bd2a69017..483d33238 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -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"))); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index af5c74969..61b1599a1 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -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); diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml index cad18921a..5a961bd5d 100644 --- a/cockatrice/themes/Fabric/palette-default-dark.toml +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -71,4 +71,6 @@ Accent = #4a82e6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml index f5151af3e..a958b39d1 100644 --- a/cockatrice/themes/Fabric/palette-default-light.toml +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -71,4 +71,6 @@ Accent = #2f6fd6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 +Positive = #1b7f3b +Negative = #b3261e diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index b180e9b63..8be52dc5f 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -71,3 +71,5 @@ Accent = #ff1e1e1e [AppColors] AccentStrong = #ff139740 AccentSoft = #ffc9fd62 +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 5a1163a2b..9548af916 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -71,3 +71,5 @@ Accent = #fff0f0f0 [AppColors] AccentStrong = #ff139740 AccentSoft = #ffc9fd62 +Positive = #1b7f3b +Negative = #b3261e diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml index b0db0e90a..26bee5be3 100644 --- a/cockatrice/themes/Leather/palette-default-dark.toml +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -71,4 +71,6 @@ Accent = #c9995a [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml index ff122db76..0219fce8e 100644 --- a/cockatrice/themes/Leather/palette-default-light.toml +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -71,4 +71,6 @@ Accent = #a5712f [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f +Positive = #1b7f3b +Negative = #b3261e diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml index 222456f4f..e013e3602 100644 --- a/cockatrice/themes/Plasma/palette-default-dark.toml +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -71,4 +71,6 @@ Accent = #6a4df0 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml index d54db31d2..71d86917c 100644 --- a/cockatrice/themes/Plasma/palette-default-light.toml +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -71,4 +71,6 @@ Accent = #5a3ee2 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 +Positive = #1b7f3b +Negative = #b3261e diff --git a/cockatrice/themes/System/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml index 9dfe6e19f..9f945fa30 100644 --- a/cockatrice/themes/System/palette-default-dark.toml +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -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 diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml index 9dfe6e19f..32cf7845c 100644 --- a/cockatrice/themes/System/palette-default-light.toml +++ b/cockatrice/themes/System/palette-default-light.toml @@ -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 diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml index 496943931..56d3e2c4c 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-dark.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -71,4 +71,6 @@ Accent = #8fa6c0 [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml index 5323fd320..04461013b 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-light.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -71,4 +71,6 @@ Accent = #54687e [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 +Positive = #1b7f3b +Negative = #b3261e diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index f12db5b16..3b9d5f818 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -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)); +} diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index d77c2f62d..fa9438107 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -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)