diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index 6bcfdc713..345b96c17 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -1,6 +1,7 @@ #include "palette_generator.h" #include "../theme_config.h" +#include "../theme_icon_ink.h" #include #include @@ -167,10 +168,19 @@ 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)); + // Neutral icon ink follows the generated accent at the same three emphasis + // levels a hand-authored theme stores, so the preview matches what the icon + // engine renders before the palette is saved. + const QColor iconInk = ThemeIconInk::glyphInk(winText, bg_win, hl); + cfg.appColors[AppColor::Icon] = iconInk; + cfg.appColors[AppColor::IconStrong] = ThemeIconInk::forEmphasis(hl, bg_win, IconEmphasis::Primary); + cfg.appColors[AppColor::IconSoft] = ThemeIconInk::forEmphasis(iconInk, bg_win, IconEmphasis::Quiet); // 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); + for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, + AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + cfg.appColors[role] = defaultAppColor(role, 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 3fec389da..73e821b90 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -65,10 +65,19 @@ static const QMap APP_ROLE_DESCRIPTIONS = { QT_TRANSLATE_NOOP("PaletteGridWidget", "Vivid primary accent (e.g. home-tab button gradient start)")}, {AppColor::AccentSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Lightened, desaturated accent (e.g. home-tab button gradient end)")}, + {AppColor::Icon, QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink at the default action weight")}, + {AppColor::IconStrong, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink for the one action a surface exists for")}, + {AppColor::IconSoft, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink for a glyph that labels rather than acts")}, {AppColor::Positive, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state and additive actions (ready, add, increment)")}, + {AppColor::PositiveStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state at primary emphasis")}, + {AppColor::PositiveSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state at quiet emphasis")}, {AppColor::Negative, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state and removal actions (not ready, remove, decrement)")}, + {AppColor::NegativeStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at primary emphasis")}, + {AppColor::NegativeSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at quiet emphasis")}, }; PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 42d7a4691..f5a3cff7e 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -458,12 +458,25 @@ QColor defaultAppColor(AppColor::Role role, bool dark) switch (role) { case AppColor::AccentStrong: case AppColor::AccentSoft: - // Derived from the palette highlight by ThemeManager::appColor(). + // The neutral icon family is palette-derived like the accent, so a + // theme without a palette of its own (System) keeps following the + // platform instead of pinning one scheme's ink. + case AppColor::Icon: + case AppColor::IconStrong: + case AppColor::IconSoft: return {}; case AppColor::Positive: return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1b7f3b")); + case AppColor::PositiveStrong: + return QColor(dark ? QStringLiteral("#99e2ae") : QStringLiteral("#104a22")); + case AppColor::PositiveSoft: + return QColor(dark ? QStringLiteral("#5e8873") : QStringLiteral("#5d7f6f")); case AppColor::Negative: return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e")); + case AppColor::NegativeStrong: + return QColor(dark ? QStringLiteral("#ffb3ac") : QStringLiteral("#7c1b15")); + case AppColor::NegativeSoft: + return QColor(dark ? QStringLiteral("#9b7678") : QStringLiteral("#9a6c6f")); } return {}; diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 7d05e4bf0..4d2cb74ee 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -13,6 +13,13 @@ // set. Stored in the same palette-.toml under an [AppColors] section // and editable from the palette editor, so theme authors can control colors // beyond what Qt's palette can express. +// +// The icon families carry one role per emphasis level. Icon is the neutral +// glyph ink; IconStrong and IconSoft are its primary and quiet levels. Positive +// and Negative follow the same pattern, so an emphasis never has to be computed +// from a hue at paint time: the theme authors all three levels. Naming a variant +// explicitly (`IconStrong`) pins that level; naming the family (`Icon`) lets the +// caller's emphasis choose. namespace AppColor { Q_NAMESPACE @@ -20,8 +27,15 @@ enum Role { AccentStrong, AccentSoft, + Icon, + IconStrong, + IconSoft, Positive, + PositiveStrong, + PositiveSoft, Negative, + NegativeStrong, + NegativeSoft, }; Q_ENUM_NS(Role) } // namespace AppColor @@ -47,9 +61,13 @@ Q_ENUM_NS(Role) * @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 default that suits the - * scheme, so themes written before the role existed still render it. + * Accent roles and the neutral Icon family return an invalid QColor because + * ThemeManager derives them from the palette: the accent from the highlight, + * the icon ink from the neutral text color plus the highlight. Semantic roles + * return an opaque default that suits the scheme, so themes written before the + * role existed still render it. A theme with no palette of its own (the System + * theme) keeps deriving its icon ink from the platform palette instead of + * pinning one scheme's colors. */ QColor defaultAppColor(AppColor::Role role, bool dark); diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index 3557a9441..09e0fb140 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -2,7 +2,6 @@ #include "libcockatrice/utility/svg_recolor.h" #include "theme_icon_badge.h" -#include "theme_icon_fill.h" #include "theme_icon_ink.h" #include "theme_icon_sticker.h" #include "theme_manager.h" @@ -318,6 +317,35 @@ private: mutable bool hasResolvedAsset = false; mutable bool hasIntrinsicSize = false; }; + +/** + * @brief The role an emphasis level selects inside a base role's family. + * + * The theme authors all three levels, so emphasis is a lookup instead of a + * color computation. Naming a variant (`IconStrong`, `PositiveSoft`) is an + * explicit request and keeps that role at every emphasis; only the base roles + * take part in the mapping. Accent roles have no neutral middle member, so they + * are always used as named. + */ +AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis) +{ + switch (role) { + case AppColor::Icon: + return emphasis == IconEmphasis::Primary ? AppColor::IconStrong + : emphasis == IconEmphasis::Quiet ? AppColor::IconSoft + : AppColor::Icon; + case AppColor::Positive: + return emphasis == IconEmphasis::Primary ? AppColor::PositiveStrong + : emphasis == IconEmphasis::Quiet ? AppColor::PositiveSoft + : AppColor::Positive; + case AppColor::Negative: + return emphasis == IconEmphasis::Primary ? AppColor::NegativeStrong + : emphasis == IconEmphasis::Quiet ? AppColor::NegativeSoft + : AppColor::Negative; + default: + return role; + } +} } // namespace IconTint::IconTint(QPalette::ColorRole role, IconEmphasis _emphasis) : paletteRole(role), emphasis(_emphasis) @@ -342,7 +370,6 @@ IconTint IconTint::quiet() QColor IconTint::resolve(QIcon::Mode mode) const { const QPalette &palette = qApp->palette(); - const QColor surface = palette.color(QPalette::Active, QPalette::Window); const QColor accent = palette.color(QPalette::Active, QPalette::Highlight); if (source == Source::AppColor) { @@ -350,13 +377,11 @@ QColor IconTint::resolve(QIcon::Mode mode) const if (mode == QIcon::Disabled) { return palette.color(QPalette::Disabled, QPalette::WindowText); } - // The three treatments paint the same tone: a line glyph, a badge disc - // and a sticker fill must not disagree on how saturated the role is. - // The fill treatments apply this floor themselves, so this only changes - // the line path, and only for the desaturated dark-scheme tones. The - // emphasis then decides how loud that tone is, without changing its hue. - const QColor color = - ThemeIconInk::forEmphasis(ThemeIconFill::saturated(themeManager->appColor(appRole)), surface, emphasis); + // The theme authors one color per emphasis level, so the paint path + // uses the resolved role verbatim. The filled treatments apply their + // own saturation floor when a disc or a fill needs mass; a line glyph + // keeps the color the author picked. + const QColor color = themeManager->appColor(emphasisRole(appRole, emphasis)); if (mode != QIcon::Selected) { return color; } @@ -373,14 +398,10 @@ QColor IconTint::resolve(QIcon::Mode mode) const const QPalette::ColorRole effective = mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole; const QColor color = palette.color(group, effective); - // A neutral label role is inked as an icon, not as body text: it borrows a - // little accent chroma and is held to a contrast floor. Semantic roles and - // the selected-row swap keep the color the palette gave them. + // A neutral label role resolves to the theme's authored icon ink. Semantic + // roles and the selected-row swap keep the color the palette gave them. if (group == QPalette::Active && (effective == QPalette::WindowText || effective == QPalette::Text)) { - // The primary action of a surface takes the accent itself; the other - // levels stay neutral. Emphasis then sets the contrast the glyph pays. - const QColor base = emphasis == IconEmphasis::Primary ? accent : ThemeIconInk::glyphInk(color, surface, accent); - return ThemeIconInk::forEmphasis(base, surface, emphasis); + return themeManager->appColor(emphasisRole(AppColor::Icon, emphasis)); } return color; } diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h index 231221c93..f09267a91 100644 --- a/cockatrice/src/interface/theme_icon.h +++ b/cockatrice/src/interface/theme_icon.h @@ -47,18 +47,20 @@ enum class IconStyle }; /** - * @brief Selects how much attention a glyph claims inside its surface. + * @brief Selects which authored level of a color family a glyph uses. * - * Tint says which hue, style says how much shape, emphasis says how loud. The - * three are independent: a primary action can be a line glyph in the theme - * accent, and a quiet one can be a sticker in a dimmed tone. + * Tint says which family, style says how much shape, emphasis says how loud. + * The three are independent: a primary action can be a line glyph in the + * theme's primary ink, and a quiet one can be a sticker in that family's quiet + * level. * - * Secondary is the default and the color every icon had before the axis - * existed, so existing call sites keep their look. Primary is for the one - * action a surface exists for: a neutral primary glyph takes the accent, and a - * tinted one is pushed to full contrast. Quiet is for glyphs that label rather - * than act, such as the magnifier inside a search field: legible, but never - * competing with the buttons next to it. + * Each family (neutral icon, positive, negative) stores one color per level, so + * emphasis is a lookup rather than a computation: the palette editor's rows are + * the whole source of truth. Secondary is the default and the color every icon + * had before the axis existed, so existing call sites keep their look. Primary + * is for the one action a surface exists for. Quiet is for glyphs that label + * rather than act, such as the magnifier inside a search field: legible, but + * never competing with the buttons next to it. */ enum class IconEmphasis { @@ -93,19 +95,23 @@ public: * * A disabled application color resolves to the disabled foreground, so an * unavailable control does not keep its semantic color. A selected palette - * color keeps the WindowText -> HighlightedText swap. Application colors go - * through the saturation floor shared by the filled treatments, so a line - * glyph, a badge disc and a sticker fill carry the same tone. + * color keeps the WindowText -> HighlightedText swap. + * + * Application colors are returned verbatim: the theme author stores one + * value per emphasis level, so the paint path does not rewrite it. The + * filled treatments apply their own saturation floor to a fill they have to + * carry; a line glyph keeps the author's color. * * Neutral label roles (WindowText, Text) resolve to icon ink rather than the - * raw palette color: ThemeIconInk::glyphInk borrows a little accent chroma - * and holds the glyph to a contrast floor, because a 2px glyph in body-text - * ink reads thinner than the text beside it. Semantic roles keep the hue the - * call site asked for. + * raw palette color: ThemeIconInk::glyphInk derives it from the accent and + * holds the glyph to a contrast floor, because a 2px glyph in body-text ink + * reads thinner than the text beside it. A theme that authors the neutral + * icon roles overrides that derivation. Semantic roles keep the hue the call + * site asked for. * - * Emphasis is applied after the hue: Primary lifts a neutral glyph to the - * accent and pushes a tinted one to full contrast; Quiet blends either - * toward the surface down to the non-text legibility floor. + * Emphasis selects the family's authored variant, so the palette editor's + * rows are the whole source of truth. A caller that names a variant + * explicitly (a strong or soft role) pins that level at every emphasis. */ QColor resolve(QIcon::Mode mode) const; diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 1cf62ae8b..6b333d4cf 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -3,6 +3,7 @@ #include "../../client/settings/cache_settings.h" #include "pixel_map_generator.h" #include "theme_config.h" +#include "theme_icon_ink.h" #include #include @@ -651,8 +652,33 @@ 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::Icon: + case AppColor::IconStrong: + case AppColor::IconSoft: { + // Legacy fallback for a theme written before the icon roles + // existed, or one with no palette of its own (System). The three + // levels are the ink rule this series shipped, applied once here so + // authored roles never get recomputed at paint time. + const QPalette &palette = qApp->palette(); + const QColor surface = palette.color(QPalette::Active, QPalette::Window); + const QColor ink = + ThemeIconInk::glyphInk(palette.color(QPalette::Active, QPalette::WindowText), surface, accent); + switch (role) { + case AppColor::IconStrong: + return ThemeIconInk::forEmphasis(accent, surface, IconEmphasis::Primary); + case AppColor::IconSoft: + return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Quiet); + case AppColor::Icon: + default: + return ink; + } + } case AppColor::Positive: + case AppColor::PositiveStrong: + case AppColor::PositiveSoft: case AppColor::Negative: + case AppColor::NegativeStrong: + case AppColor::NegativeSoft: // 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()); diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml index 5a961bd5d..2412f7c03 100644 --- a/cockatrice/themes/Fabric/palette-default-dark.toml +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -71,6 +71,13 @@ Accent = #4a82e6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 +Icon = #7b9fe4 +IconStrong = #b1c7ef +IconSoft = #70809d Positive = #6dd58c +PositiveStrong = #99e2ae +PositiveSoft = #5e8873 Negative = #ff8a80 +NegativeStrong = #ffb3ac +NegativeSoft = #9b7678 diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml index a958b39d1..f122a5aeb 100644 --- a/cockatrice/themes/Fabric/palette-default-light.toml +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -71,6 +71,13 @@ Accent = #2f6fd6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 +Icon = #2f59ad +IconStrong = #213e79 +IconSoft = #687899 Positive = #1b7f3b +PositiveStrong = #104a22 +PositiveSoft = #5d7f6f Negative = #b3261e +NegativeStrong = #7c1b15 +NegativeSoft = #9a6c6f diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index 8be52dc5f..ece7633ac 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -69,7 +69,15 @@ Accent = #ff1e1e1e [AppColors] -AccentStrong = #ff139740 -AccentSoft = #ffc9fd62 -Positive = #6dd58c -Negative = #ff8a80 +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 +Icon = #5db87c +IconStrong = #aad9b9 +IconSoft = #6e8d79 +Positive = #6dd58c +PositiveStrong = #99e2ae +PositiveSoft = #698e74 +Negative = #ff8a80 +NegativeStrong = #ffbfb9 +NegativeSoft = #a37c78 + diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 9548af916..3465168eb 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -69,7 +69,15 @@ Accent = #fff0f0f0 [AppColors] -AccentStrong = #ff139740 -AccentSoft = #ffc9fd62 -Positive = #1b7f3b -Negative = #b3261e +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 +Icon = #107e36 +IconStrong = #0b5826 +IconSoft = #6c937a +Positive = #1b7f3b +PositiveStrong = #135929 +PositiveSoft = #70927b +Negative = #b3261e +NegativeStrong = #7d1b15 +NegativeSoft = #b37d7b + diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml index 26bee5be3..ab5fe82b7 100644 --- a/cockatrice/themes/Leather/palette-default-dark.toml +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -71,6 +71,13 @@ Accent = #c9995a [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f +Icon = #bc8c4a +IconStrong = #d4b68c +IconSoft = #86745a Positive = #6dd58c +PositiveStrong = #99e2ae +PositiveSoft = #588061 Negative = #ff8a80 +NegativeStrong = #ffada6 +NegativeSoft = #996b66 diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml index 0219fce8e..df1d50978 100644 --- a/cockatrice/themes/Leather/palette-default-light.toml +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -71,6 +71,13 @@ Accent = #a5712f [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f +Icon = #735125 +IconStrong = #4e3719 +IconSoft = #817059 Positive = #1b7f3b +PositiveStrong = #0f4520 +PositiveSoft = #61795d Negative = #b3261e +NegativeStrong = #741914 +NegativeSoft = #9a665b diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml index e013e3602..487f0dff4 100644 --- a/cockatrice/themes/Plasma/palette-default-dark.toml +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -71,6 +71,13 @@ Accent = #6a4df0 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 +Icon = #927fea +IconStrong = #b7aaf1 +IconSoft = #736b97 Positive = #6dd58c +PositiveStrong = #99e2ae +PositiveSoft = #517a63 Negative = #ff8a80 +NegativeStrong = #ffada6 +NegativeSoft = #926567 diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml index 71d86917c..16199858b 100644 --- a/cockatrice/themes/Plasma/palette-default-light.toml +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -71,6 +71,13 @@ Accent = #5a3ee2 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 +Icon = #5439d0 +IconStrong = #39278e +IconSoft = #7569a4 Positive = #1b7f3b +PositiveStrong = #0e431f +PositiveSoft = #5c776d Negative = #b3261e +NegativeStrong = #711913 +NegativeSoft = #95646d diff --git a/cockatrice/themes/System/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml index 9f945fa30..ae821b739 100644 --- a/cockatrice/themes/System/palette-default-dark.toml +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -3,8 +3,14 @@ # 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. +# Icon, IconStrong and IconSoft are deliberately absent: a theme with no +# palette of its own derives its icon ink from the platform palette. [AppColors] -AccentStrong = #ff148c3c -AccentSoft = #ff78c850 -Positive = #ff6dd58c -Negative = #ffff8a80 +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 +Positive = #ff6dd58c +PositiveStrong = #ff99e2ae +PositiveSoft = #ff5e8873 +Negative = #ffff8a80 +NegativeStrong = #ffffb3ac +NegativeSoft = #ff9b7678 diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml index 32cf7845c..da4dac445 100644 --- a/cockatrice/themes/System/palette-default-light.toml +++ b/cockatrice/themes/System/palette-default-light.toml @@ -3,8 +3,14 @@ # 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. +# Icon, IconStrong and IconSoft are deliberately absent: a theme with no +# palette of its own derives its icon ink from the platform palette. [AppColors] -AccentStrong = #ff148c3c -AccentSoft = #ff78c850 -Positive = #ff1b7f3b -Negative = #ffb3261e +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 +Positive = #ff1b7f3b +PositiveStrong = #ff104a22 +PositiveSoft = #ff5d7f6f +Negative = #ffb3261e +NegativeStrong = #ff7c1b15 +NegativeSoft = #ff9a6c6f diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml index 56d3e2c4c..238a7e40c 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-dark.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -71,6 +71,13 @@ Accent = #8fa6c0 [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 +Icon = #8392a1 +IconStrong = #acb6c0 +IconSoft = #6d737a Positive = #6dd58c +PositiveStrong = #99e2ae +PositiveSoft = #517c61 Negative = #ff8a80 +NegativeStrong = #ffada6 +NegativeSoft = #926865 diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml index 04461013b..a5ba5e959 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-light.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -71,6 +71,13 @@ Accent = #54687e [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 +Icon = #4a5b6f +IconStrong = #333e4c +IconSoft = #6e7580 Positive = #1b7f3b +PositiveStrong = #0f4620 +PositiveSoft = #5b7b6a Negative = #b3261e +NegativeStrong = #761a14 +NegativeSoft = #96696b diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index f51282e0a..e5156f354 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include namespace { @@ -332,28 +333,26 @@ TEST_F(ThemeIconRenderTest, SelectedAppColorKeepsAContrastingTint) } /** - * @brief Line glyphs share the filled treatments' saturation floor. + * @brief A line glyph renders the authored role verbatim. * - * The dark-scheme semantic tones are the visible case, but `AccentSoft` is below - * the floor in every shipped light palette, so the resolve path is exercised - * without swapping the theme. A line glyph next to a badge or a sticker has to - * carry the same tone; reverting the floor here would leave it pale beside them. + * The emphasis colors are authored data now, so the paint path must not + * rewrite them. A muted quiet role stays muted on a line glyph; a badge disc or + * a sticker fill boosts its own fill, because carrying a whole shape is a + * treatment concern rather than a role concern. */ -TEST_F(ThemeIconRenderTest, LineTintAppliesTheFillSaturationFloor) +TEST_F(ThemeIconRenderTest, LineTintRendersTheAuthoredRoleVerbatim) { const QColor raw = themeManager->appColor(AppColor::AccentSoft); ASSERT_TRUE(raw.isValid()); ASSERT_LT(raw.hsvSaturation(), 170); - const QColor saturated = ThemeIconFill::saturated(raw); - EXPECT_NE(saturated, raw); - EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Normal), saturated); + EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Normal), raw); const QIcon icon = loadThemedIcon(QStringLiteral("icons/arrow_right"), AppColor::AccentSoft); const QPixmap pixmap = icon.pixmap(QSize(24, 24)); ASSERT_FALSE(pixmap.isNull()); - EXPECT_GT(countOpaquePixels(pixmap, saturated), 0); - EXPECT_EQ(countOpaquePixels(pixmap, raw), 0); + EXPECT_GT(countOpaquePixels(pixmap, raw), 0); + EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(raw)), 0); } /** @brief A badge is a disc with a gradient fill, a dark ring and a contrast glyph. */ @@ -1131,6 +1130,7 @@ struct InkPalette QColor window; QColor button; QColor accent; + QMap roles; }; /** @@ -1146,12 +1146,30 @@ InkPalette shippedPalette(const QString &theme, bool dark) .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); const PaletteConfig config = PaletteConfig::fromFile(file); - if (!config.hasPalette()) { + // A theme may author only [AppColors] (System does), so appColors alone do + // not mean it ships a palette: the keys have to be present. + if (config.colors.isEmpty()) { return {}; } const QPalette palette = config.apply(QPalette()); return {palette.color(QPalette::Active, QPalette::WindowText), palette.color(QPalette::Active, QPalette::Window), - palette.color(QPalette::Active, QPalette::Button), palette.color(QPalette::Active, QPalette::Highlight)}; + palette.color(QPalette::Active, QPalette::Button), palette.color(QPalette::Active, QPalette::Highlight), + config.appColors}; +} + +/** + * @brief The [AppColors] a shipped theme authors, even when it ships no palette. + * + * System deliberately has no palette of its own, but it still authors its + * semantic roles; the icon roles stay unset there so they follow the platform. + */ +QMap shippedAppColors(const QString &theme, bool dark) +{ + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + return PaletteConfig::fromFile(file).appColors; } } // namespace @@ -1365,107 +1383,224 @@ bool applyShippedPalette(const QString &theme, bool dark) qApp->setPalette(config.apply(QPalette())); return true; } - -QColor surfaceColor() -{ - return qApp->palette().color(QPalette::Active, QPalette::Window); -} } // namespace /** - * @brief The primary action of a surface takes the accent, not a darker gray. + * @brief Emphasis selects the authored variant of a family, not a computed color. * - * A glyph hierarchy cannot be built out of "dark" and "darker": at any usable - * contrast a neutral ink is already near the palette's pole, so the top level - * has to be a different color. The accent is the palette's own answer to "which - * color means action", so primary uses it instead of inventing a token. + * The theme stores one color per level, so the mapping is a lookup: a base role + * follows the caller's emphasis, while naming a variant pins that level at every + * emphasis. This is the contract that makes the palette editor's rows the whole + * source of truth. */ -TEST_F(ThemeIconRenderTest, PrimaryEmphasisUsesTheAccent) +TEST_F(ThemeIconRenderTest, EmphasisSelectsTheAuthoredVariant) { - ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + EXPECT_EQ(IconTint().resolve(QIcon::Normal), themeManager->appColor(AppColor::Icon)); + EXPECT_EQ(IconTint::primary().resolve(QIcon::Normal), themeManager->appColor(AppColor::IconStrong)); + EXPECT_EQ(IconTint::quiet().resolve(QIcon::Normal), themeManager->appColor(AppColor::IconSoft)); + EXPECT_EQ(IconTint(QPalette::WindowText, IconEmphasis::Primary).resolve(QIcon::Normal), + themeManager->appColor(AppColor::IconStrong)); - const QColor secondary = IconTint().resolve(QIcon::Normal); - const QColor primary = IconTint::primary().resolve(QIcon::Normal); + struct Family + { + AppColor::Role base; + AppColor::Role strong; + AppColor::Role soft; + }; - EXPECT_NE(primary, secondary); - EXPECT_GT(primary.blue(), primary.red()) << "the primary glyph did not take the accent's blue"; - EXPECT_GE(ThemeIconInk::contrastRatio(primary, surfaceColor()), ThemeIconInk::primaryContrastFloor()); - EXPECT_GE(ThemeIconInk::contrastRatio(primary, surfaceColor()), - ThemeIconInk::contrastRatio(secondary, surfaceColor())); + for (const Family &family : {Family{AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}, + Family{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + Family{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + EXPECT_EQ(IconTint(family.base, IconEmphasis::Primary).resolve(QIcon::Normal), + themeManager->appColor(family.strong)); + EXPECT_EQ(IconTint(family.base).resolve(QIcon::Normal), themeManager->appColor(family.base)); + EXPECT_EQ(IconTint(family.base, IconEmphasis::Quiet).resolve(QIcon::Normal), + themeManager->appColor(family.soft)); + // Naming a variant is explicit: emphasis does not rewrite it. + EXPECT_EQ(IconTint(family.strong, IconEmphasis::Quiet).resolve(QIcon::Normal), + themeManager->appColor(family.strong)); + EXPECT_EQ(IconTint(family.soft, IconEmphasis::Primary).resolve(QIcon::Normal), + themeManager->appColor(family.soft)); + } } /** - * @brief The primary level out-reads the default one on both schemes. + * @brief The authored primary level out-reads the default one on both schemes. * - * The default ink borrows enough accent to sit off the palette's pole (5.4:1 on - * light, 6.4:1 on dark), so the 7:1 primary floor is measurably above it in both - * schemes instead of being lost against a near-black or near-white default. - * Hue alone would not keep the order; the contrast target does. + * The three levels are authored against the window and button surfaces, so the + * ordering has to be visible on both, not just on the window the palette is + * usually previewed against. */ TEST_F(ThemeIconRenderTest, PrimaryOutReadsSecondaryOnBothSchemes) { for (const bool dark : {false, true}) { - ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), dark)); - const QColor surface = surfaceColor(); - const QColor secondary = IconTint().resolve(QIcon::Normal); - const QColor primary = IconTint::primary().resolve(QIcon::Normal); - EXPECT_NE(primary, secondary) << (dark ? "dark" : "light"); - EXPECT_GT(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::contrastRatio(secondary, surface)) - << (dark ? "dark" : "light"); - EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor()) - << (dark ? "dark" : "light"); + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); + ASSERT_TRUE(palette.text.isValid()) << (dark ? "dark" : "light"); + const QColor secondary = palette.roles.value(AppColor::Icon); + const QColor primary = palette.roles.value(AppColor::IconStrong); + ASSERT_TRUE(secondary.isValid() && primary.isValid()) << (dark ? "dark" : "light"); + + for (const QColor &surface : {palette.window, palette.button}) { + EXPECT_GT(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::contrastRatio(secondary, surface)) + << (dark ? "dark" : "light"); + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor()) + << (dark ? "dark" : "light"); + } } } /** @brief A quiet glyph recedes toward its surface without dropping below 3:1. */ TEST_F(ThemeIconRenderTest, QuietEmphasisRecedesButStaysLegible) { - ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); + ASSERT_TRUE(palette.text.isValid()) << (dark ? "dark" : "light"); + const QColor secondary = palette.roles.value(AppColor::Icon); + const QColor quiet = palette.roles.value(AppColor::IconSoft); + ASSERT_TRUE(secondary.isValid() && quiet.isValid()) << (dark ? "dark" : "light"); - const QColor secondary = IconTint().resolve(QIcon::Normal); - const QColor quiet = IconTint::quiet().resolve(QIcon::Normal); - - EXPECT_LT(ThemeIconInk::contrastRatio(quiet, surfaceColor()), - ThemeIconInk::contrastRatio(secondary, surfaceColor())) - << "the quiet glyph does not recede"; - EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surfaceColor()), ThemeIconInk::quietContrastFloor()); + for (const QColor &surface : {palette.window, palette.button}) { + EXPECT_LT(ThemeIconInk::contrastRatio(quiet, surface), ThemeIconInk::contrastRatio(secondary, surface)) + << "the quiet glyph does not recede"; + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface), ThemeIconInk::quietContrastFloor()) + << (dark ? "dark" : "light"); + } + } } -/** @brief Emphasis changes how loud a semantic color is, never which color it is. */ +/** @brief Every authored emphasis level keeps a semantic family's hue. */ TEST_F(ThemeIconRenderTest, EmphasisKeepsASemanticHue) { - ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); - for (const IconEmphasis emphasis : {IconEmphasis::Primary, IconEmphasis::Secondary, IconEmphasis::Quiet}) { - const QColor ink = IconTint(AppColor::Positive, emphasis).resolve(QIcon::Normal); - EXPECT_GT(ink.green(), ink.red()) << static_cast(emphasis); - EXPECT_GT(ink.green(), ink.blue()) << static_cast(emphasis); + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const QMap roles = shippedAppColors(theme, dark); + + for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}) { + const QColor ink = roles.value(role); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.green(), ink.red()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.green(), ink.blue()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : {AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + const QColor ink = roles.value(role); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.red(), ink.blue()) << theme.toStdString() << static_cast(role); + } + } } + EXPECT_GT(checked, 0) << "no shipped theme authored a semantic emphasis level"; +} + +/** + * @brief Every palette-shipping theme authors the whole emphasis ladder. + * + * The palette editor treats an unset role as "derive it", so a missing key is + * not a crash: it silently reintroduces the computed colors this change exists + * to remove. Treat a missing role as a test failure instead, and keep the same + * contrast contract the old computed levels had. + */ +TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder) +{ + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); + + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + if (!palette.text.isValid()) { + continue; // System ships no palette and derives its icon ink. + } + ++checked; + + for (const AppColor::Role role : {AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft, + AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, + AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + ASSERT_TRUE(palette.roles.contains(role)) + << theme.toStdString() << (dark ? " dark" : " light") << static_cast(role); + } + + for (const QColor &surface : {palette.window, palette.button}) { + const double secondary = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::Icon), surface); + const double primary = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::IconStrong), surface); + const double quiet = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::IconSoft), surface); + EXPECT_GE(secondary, ThemeIconInk::labelContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GE(primary, ThemeIconInk::primaryContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GT(primary, secondary) << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GE(quiet, ThemeIconInk::quietContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_LT(quiet, secondary) << theme.toStdString() << (dark ? " dark" : " light"); + + for (const auto &[base, strong, soft] : + {std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + const double baseContrast = ThemeIconInk::contrastRatio(palette.roles.value(base), surface); + EXPECT_GE(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface), + ThemeIconInk::quietContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_LT(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface), baseContrast) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GT(ThemeIconInk::contrastRatio(palette.roles.value(strong), surface), baseContrast) + << theme.toStdString() << (dark ? " dark" : " light"); + } + } + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; + + // The System theme is the deliberate exception: it authors its semantic + // levels but leaves the neutral family to the platform palette. + const QMap system = shippedAppColors(QStringLiteral("System"), false); + EXPECT_TRUE(system.contains(AppColor::PositiveStrong)); + EXPECT_TRUE(system.contains(AppColor::NegativeSoft)); + EXPECT_FALSE(system.contains(AppColor::Icon)); + EXPECT_FALSE(system.contains(AppColor::IconStrong)); + EXPECT_FALSE(system.contains(AppColor::IconSoft)); } /** @brief Every emphasis keeps the selected-row swap to HighlightedText. */ TEST_F(ThemeIconRenderTest, SelectedStillFallsBackToHighlightedText) { + const QColor tone = IconTint(AppColor::AccentSoft).resolve(QIcon::Normal); + ASSERT_TRUE(tone.isValid()); + QPalette palette = qApp->palette(); const QColor selectedText(QStringLiteral("#222222")); - palette.setColor(QPalette::Active, QPalette::Highlight, QColor(QStringLiteral("#000000"))); + palette.setColor(QPalette::Active, QPalette::Highlight, tone); palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText); qApp->setPalette(palette); - // Black ink on a black highlight is illegible, so the selection wins for - // both the default and the quiet level. + // Tone on a highlight of the same color is illegible, so the selection wins + // for both the default and an explicitly tinted glyph. EXPECT_EQ(IconTint().resolve(QIcon::Selected), selectedText); - EXPECT_EQ(IconTint::quiet().resolve(QIcon::Selected), selectedText); + EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Selected), selectedText); } /** - * @brief Writes the emphasis ladder sheets for every shipped palette theme. + * @brief Writes the authored emphasis ladder when asked; skipped otherwise. * - * One sheet per theme and scheme: the neutral, positive and negative families at - * their quiet, secondary and primary levels, each as a line glyph, a sticker and - * a badge, three glyphs per family, at 16 and 24 px, on the window and button - * surfaces. The tone is the one the emphasis rule resolves for that surface, so - * the sheet is what the engine paints rather than a hand-picked sample. + * One sheet per scheme showing what a theme author actually stores: the neutral, + * positive and negative families, each at its quiet, secondary and primary + * level. The paint path is a lookup, so this is the visual proof that the + * palette rows and the rendered glyphs agree. */ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) { @@ -1476,164 +1611,91 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) ASSERT_TRUE(QDir().mkpath(outDir)); constexpr int MARGIN = 24; - constexpr int LABEL_WIDTH = 230; - constexpr int CELL_WIDTH = 166; - constexpr int ROW_HEIGHT = 58; - constexpr int PANEL_TITLE = 26; - constexpr int COLUMN_HEADER = 20; - constexpr int PANEL_PADDING = 12; - constexpr int GLYPH_SLOT = 32; + constexpr int HEADER_HEIGHT = 34; + constexpr int ROW_HEIGHT = 38; + constexpr int ICON_SIZE = 20; constexpr qreal DPR = 2.0; - constexpr int SIZES[] = {16, 24}; - constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"}; - constexpr const char *LEVEL_NAMES[] = {"Quiet", "Secondary", "Primary"}; - constexpr IconEmphasis LEVELS[] = {IconEmphasis::Quiet, IconEmphasis::Secondary, IconEmphasis::Primary}; + constexpr int WIDTH = 760; struct Family { const char *name; - const char *glyphs[3]; + const char *glyph; + AppColor::Role quiet; + AppColor::Role secondary; + AppColor::Role primary; + }; + struct Sample + { + const char *level; AppColor::Role role; - bool neutral; }; + const QList families = { - {"Neutral", {"settings", "info", "player"}, AppColor::Positive, true}, - {"Positive", {"increment", "ready_start", "arrow_up"}, AppColor::Positive, false}, - {"Negative", {"decrement", "not_ready_start", "remove_row"}, AppColor::Negative, false}, - }; - const QStringList themes = { - QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), - QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), + {"Neutral", "settings", AppColor::IconSoft, AppColor::Icon, AppColor::IconStrong}, + {"Positive", "increment", AppColor::PositiveSoft, AppColor::Positive, AppColor::PositiveStrong}, + {"Negative", "decrement", AppColor::NegativeSoft, AppColor::Negative, AppColor::NegativeStrong}, }; - const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) { - QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); - QPixmap pixmap(size); - pixmap.fill(Qt::transparent); - QPainter painter(&pixmap); + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); + ASSERT_TRUE(palette.text.isValid()); + ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), dark)); + + const int logicalHeight = MARGIN + 30 + families.size() * (HEADER_HEIGHT + 3 * ROW_HEIGHT) + MARGIN; + QImage image(QSize(qRound(WIDTH * DPR), qRound(logicalHeight * DPR)), QImage::Format_ARGB32_Premultiplied); + image.fill(palette.window); + QPainter painter(&image); painter.setRenderHint(QPainter::Antialiasing, true); - renderer.render(&painter); - return pixmap; - }; + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); - const int panelWidth = LABEL_WIDTH + static_cast(std::size(TREATMENTS)) * CELL_WIDTH + PANEL_PADDING; - const int panelHeight = - PANEL_TITLE + COLUMN_HEADER + static_cast(families.size()) * 3 * ROW_HEIGHT + PANEL_PADDING; - const int logicalWidth = 2 * MARGIN + panelWidth; - const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; - - for (const QString &theme : themes) { - for (const bool dark : {false, true}) { - const InkPalette palette = shippedPalette(theme, dark); - ASSERT_TRUE(palette.text.isValid()) << theme.toStdString(); - - // The emphasis rule reads the live palette, so apply the theme - // before resolving tones for the sheet. - ASSERT_TRUE(applyShippedPalette(theme, dark)); - - const auto toneFor = [&](const Family &family, int level) { - QColor base; - if (family.neutral) { - base = level == 2 ? palette.accent - : ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); - } else { - base = ThemeIconFill::saturated(defaultAppColor(family.role, dark)); - } - return ThemeIconInk::forEmphasis(base, palette.window, LEVELS[level]); - }; - - QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), - QImage::Format_ARGB32_Premultiplied); - image.fill(palette.window); - QPainter painter(&image); - painter.setRenderHint(QPainter::Antialiasing, true); - painter.setRenderHint(QPainter::SmoothPixmapTransform, true); - painter.scale(DPR, DPR); + qreal y = MARGIN; + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, y), (dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")) + + QStringLiteral(" — authored emphasis ladder (Fabric)")); + y += 30; + for (const Family &family : families) { painter.setPen(palette.text); painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); - painter.drawText(QPointF(MARGIN, MARGIN), - QStringLiteral("%1 — %2 scheme, emphasis across treatments") - .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + painter.drawText(QPointF(MARGIN, y), QString::fromLatin1(family.name)); + y += HEADER_HEIGHT; - struct Surface - { - const char *name; - QColor background; - QColor text; - }; - const QList surfaces = { - {"Window surface", palette.window, palette.text}, - {"Button surface", palette.button, palette.text}, + const QList samples = { + {"quiet", family.quiet}, + {"secondary", family.secondary}, + {"primary", family.primary}, }; + for (const Sample &sample : samples) { + const QColor ink = palette.roles.value(sample.role); + ASSERT_TRUE(ink.isValid()) << family.name << " " << sample.level; - qreal panelTop = MARGIN + 30; - for (const Surface &surface : surfaces) { - painter.setPen(Qt::NoPen); - painter.setBrush(surface.background); - painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8); + const QByteArray svg = readIconSvg(family.glyph); + ASSERT_FALSE(svg.isEmpty()) << family.glyph; + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap glyph(QSize(ICON_SIZE, ICON_SIZE)); + glyph.fill(Qt::transparent); + QPainter glyphPainter(&glyph); + glyphPainter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&glyphPainter); + glyphPainter.end(); + painter.drawPixmap(QPointF(MARGIN + 8, y - ICON_SIZE + 4), glyph); - const qreal contentLeft = MARGIN + PANEL_PADDING; - painter.setPen(surface.text); - painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); - painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name)); - - painter.setFont(QFont(QStringLiteral("Sans"), 8)); - for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) { - const qreal x = contentLeft + LABEL_WIDTH + static_cast(treatment) * CELL_WIDTH; - painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14), - QString::fromLatin1(TREATMENTS[treatment]) + QStringLiteral(" 16 / 24")); - } - - qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER; - for (const Family &family : families) { - for (int level = 0; level < 3; ++level) { - const QColor ink = toneFor(family, level); - ASSERT_TRUE(ink.isValid()) - << theme.toStdString() << " " << family.name << " " << LEVEL_NAMES[level]; - - painter.setPen(surface.text); - painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); - painter.drawText(QPointF(contentLeft, y + 14), - QStringLiteral("%1 / %2").arg(QString::fromLatin1(family.name), - QString::fromLatin1(LEVEL_NAMES[level]))); - painter.setFont(QFont(QStringLiteral("Mono"), 7)); - painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); - - for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) { - const qreal cellX = contentLeft + LABEL_WIDTH + static_cast(treatment) * CELL_WIDTH; - for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { - const int side = SIZES[sizeIndex]; - const QSize size(side, side); - const qreal rowTop = y + (sizeIndex == 0 ? 4 : 28); - for (std::size_t glyphIndex = 0; glyphIndex < std::size(family.glyphs); ++glyphIndex) { - const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); - ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; - QPixmap pixmap; - if (treatment == 0) { - pixmap = renderLine(svg, ink, size); - } else if (treatment == 1) { - pixmap = ThemeIconSticker::renderSticker(svg, ink, size); - } else { - pixmap = ThemeIconBadge::renderBadge(svg, ink, size); - } - const qreal x = cellX + 6 + static_cast(glyphIndex) * GLYPH_SLOT + - (GLYPH_SLOT - side) / 2.0; - painter.drawPixmap(QPointF(x, rowTop + (GLYPH_SLOT - side) / 2.0), pixmap); - } - } - } - y += ROW_HEIGHT; - } - } - panelTop += panelHeight + 24; + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 10)); + painter.drawText(QPointF(MARGIN + ICON_SIZE + 24, y), QString::fromLatin1(sample.level)); + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Mono"), 9)); + painter.drawText(QPointF(MARGIN + 200, y), ink.name(QColor::HexRgb)); + y += ROW_HEIGHT; } - - painter.end(); - const QString path = QDir(outDir).absoluteFilePath( - QStringLiteral("emphasis-preview-%1-%2.png") - .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); - ASSERT_TRUE(image.save(path)) << path.toStdString(); } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("emphasis-preview-dark.png") + : QStringLiteral("emphasis-preview-light.png")); + ASSERT_TRUE(image.save(path)) << path.toStdString(); } } diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index fa9438107..0e55e966d 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -153,11 +153,26 @@ 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::PositiveStrong, false), QColor(QStringLiteral("#104a22"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, true), QColor(QStringLiteral("#99e2ae"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, false), QColor(QStringLiteral("#5d7f6f"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, true), QColor(QStringLiteral("#5e8873"))); 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()); + EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, false), QColor(QStringLiteral("#7c1b15"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, true), QColor(QStringLiteral("#ffb3ac"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, false), QColor(QStringLiteral("#9a6c6f"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, true), QColor(QStringLiteral("#9b7678"))); + // Accent and neutral icon roles are derived from the palette, so they have + // no static default. A theme with no palette (System) keeps following the + // platform instead of pinning one scheme's ink. + for (const bool dark : {false, true}) { + EXPECT_FALSE(defaultAppColor(AppColor::AccentStrong, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::AccentSoft, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::Icon, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::IconStrong, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::IconSoft, dark).isValid()); + } } TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles) @@ -166,10 +181,20 @@ TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles) 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 QMap roles = { + {AppColor::Icon, QColor(QStringLiteral("#2f59ad"))}, + {AppColor::IconStrong, QColor(QStringLiteral("#213e79"))}, + {AppColor::IconSoft, QColor(QStringLiteral("#687899"))}, + {AppColor::Positive, QColor(QStringLiteral("#1b7f3b"))}, + {AppColor::PositiveStrong, QColor(QStringLiteral("#104a22"))}, + {AppColor::PositiveSoft, QColor(QStringLiteral("#5d7f6f"))}, + {AppColor::Negative, QColor(QStringLiteral("#b3261e"))}, + {AppColor::NegativeStrong, QColor(QStringLiteral("#7c1b15"))}, + {AppColor::NegativeSoft, QColor(QStringLiteral("#9a6c6f"))}, + }; + for (auto it = roles.cbegin(); it != roles.cend(); ++it) { + config.appColors[it.key()] = it.value(); + } const QString path = QDir(dir.path()).absoluteFilePath(QStringLiteral("palette-light.toml")); QFile file(path); @@ -178,10 +203,10 @@ TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles) 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); + for (auto it = roles.cbegin(); it != roles.cend(); ++it) { + ASSERT_TRUE(parsed.appColors.contains(it.key())); + EXPECT_EQ(parsed.appColors.value(it.key()), it.value()); + } } // Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and