diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index 6bcfdc713..d5d70c4ef 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,21 @@ 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. The harder of the two surfaces + // is the one the floor has to be solved against. + const QColor iconSurface = ThemeIconInk::harderSurface(bg_win, bg_btn, winText); + const QColor iconInk = ThemeIconInk::glyphInk(winText, iconSurface, hl); + cfg.appColors[AppColor::Icon] = iconInk; + cfg.appColors[AppColor::IconStrong] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Primary); + cfg.appColors[AppColor::IconSoft] = ThemeIconInk::forEmphasis(iconInk, iconSurface, 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..848be2231 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -458,12 +458,30 @@ 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: a theme + // that leaves it unauthored (System, and every theme written before the + // roles existed) keeps the live platform palette's WindowText instead + // of pinning one scheme's ink. + case AppColor::Icon: + case AppColor::IconStrong: + case AppColor::IconSoft: return {}; + // The semantic family follows the same chroma ladder as the authored + // themes. A theme that authors none of it has no per-surface palette to + // measure against, so these are authored against the representative + // platform surfaces the System theme runs on. case AppColor::Positive: - return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1b7f3b")); + return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1a7d3a")); + case AppColor::PositiveStrong: + return QColor(dark ? QStringLiteral("#32c05c") : QStringLiteral("#007e28")); + case AppColor::PositiveSoft: + return QColor(dark ? QStringLiteral("#8ab196") : QStringLiteral("#4c7659")); case AppColor::Negative: return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e")); + case AppColor::NegativeStrong: + return QColor(dark ? QStringLiteral("#ff8378") : QStringLiteral("#da0b00")); + case AppColor::NegativeSoft: + return QColor(dark ? QStringLiteral("#c79e9b") : QStringLiteral("#935f5c")); } return {}; diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 7d05e4bf0..68734e762 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,15 @@ 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 live WindowText held to the label floor. Semantic roles + * return an opaque default that suits the scheme, so themes written before the + * role existed still render it. The System theme deliberately leaves the + * neutral family unauthored: the palette file it picks cannot know the platform + * scheme, so a fixed ladder would be invisible on the other one. It derives + * neutral ink from the live 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..f0c9ef84f 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" @@ -31,6 +30,74 @@ namespace { +/** + * @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; + } +} + +/** + * @brief The scheme the active application palette is painting for. + * + * The fill needs the surface scheme to choose the badge glyph ink, and it cannot + * read it from the tone: an authored quiet level is a dark color on the dark + * scheme too. The theme's resolved scheme is the same source the palette file + * and the default app colors are chosen from, so the fill follows it instead of + * re-deriving the scheme from the live Window lightness, which can disagree + * when a theme pins a scheme the platform palette does not match. + */ +ThemeIconFill::Scheme activeScheme() +{ + if (themeManager != nullptr) { + return themeManager->isDarkModeActive() ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; + } + + // No manager (test binaries link the icon engine without the app): fall + // back to the live palette, which is the only scheme signal left. + const QPalette &palette = qApp->palette(); + return palette.color(QPalette::Active, QPalette::Window).lightness() < 128 ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light; +} + +/** + * @brief The ink a disabled glyph is painted with. + * + * A disabled icon used to be the palette's disabled text, a fixed gray whose + * contrast swings with the surface: on the darkest themes it out-reads the quiet + * level, so an enabled quiet glyph looks more disabled than a disabled one. + * Deriving it from the theme's own quiet ink keeps the order structural. + */ +QColor disabledGlyphInk(const QPalette &palette) +{ + const QColor quiet = themeManager->appColor(AppColor::IconSoft); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), quiet); + return ThemeIconInk::disabledInk(quiet, surface); +} + /** * @brief Reads an SVG through the `theme:` search path. */ @@ -265,7 +332,7 @@ private: QByteArray variant; if (badge) { variant = QByteArrayLiteral("B|") + tone.name(QColor::HexRgb).toLatin1() + '|' + - ThemeIconBadge::glyphColor(tone).name(QColor::HexRgb).toLatin1(); + ThemeIconBadge::glyphColor(tone, activeScheme()).name(QColor::HexRgb).toLatin1(); } else if (sticker) { variant = QByteArrayLiteral("S|") + tone.name(QColor::HexRgb).toLatin1(); } else if (resolvedHasSentinel) { @@ -283,7 +350,7 @@ private: if (badge) { // Geometry is computed from the device-pixel size, and the ratio is // attached below like every other path. - pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize); + pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize, activeScheme()); } else if (sticker) { pixmap = ThemeIconSticker::renderSticker(resolvedSvg, tone, pixelSize); } else if (resolvedHasSentinel) { @@ -318,6 +385,7 @@ private: mutable bool hasResolvedAsset = false; mutable bool hasIntrinsicSize = false; }; + } // namespace IconTint::IconTint(QPalette::ColorRole role, IconEmphasis _emphasis) : paletteRole(role), emphasis(_emphasis) @@ -342,21 +410,18 @@ 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) { // A disabled control must not keep signalling its semantic color. if (mode == QIcon::Disabled) { - return palette.color(QPalette::Disabled, QPalette::WindowText); + return disabledGlyphInk(palette); } - // 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 only shape the + // tone's value to give a disc or a fill depth; hue and saturation stay + // the author's. + const QColor color = themeManager->appColor(emphasisRole(appRole, emphasis)); if (mode != QIcon::Selected) { return color; } @@ -372,17 +437,14 @@ QColor IconTint::resolve(QIcon::Mode mode) const const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; 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. - 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); + // A neutral label role resolves to the theme's authored icon ink. A disabled + // one uses the dimmed quiet ink so it always sits below the quiet level; + // semantic roles keep the color the palette gave them. + if (effective == QPalette::WindowText || effective == QPalette::Text) { + return group == QPalette::Disabled ? disabledGlyphInk(palette) + : themeManager->appColor(emphasisRole(AppColor::Icon, emphasis)); } - return color; + return palette.color(group, effective); } QIcon loadThemedIcon(QStringView prefix, IconTint tint, IconStyle style) @@ -407,7 +469,7 @@ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const if (canvas.isEmpty()) { canvas = QSvgRenderer(svg).defaultSize(); } - return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas) + return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas, activeScheme()) : ThemeIconSticker::renderSticker(svg, tone, canvas); } diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h index 231221c93..82500292a 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 { @@ -82,7 +84,7 @@ public: IconTint(QPalette::ColorRole role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for role callers IconTint(AppColor::Role role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for semantic callers - /** @brief A neutral glyph in the accent tone, for the surface's primary action. */ + /** @brief A neutral glyph at the family's primary level, for the surface's primary action. */ static IconTint primary(); /** @brief A dimmed neutral glyph, for a control that labels rather than acts. */ @@ -91,21 +93,26 @@ public: /** * @brief Resolves the concrete color for an icon mode. * - * 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. + * A disabled application color resolves to the derived disabled ink, the + * quiet level receded toward the surface, so an unavailable control does not + * keep its semantic color. A selected palette 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 only shape that tone's value to give a shape depth; the + * hue and saturation stay the author's. * * 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: an unauthored neutral family derives it from the live + * WindowText 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_icon_badge.cpp b/cockatrice/src/interface/theme_icon_badge.cpp index 086d2be43..d5b07b3c6 100644 --- a/cockatrice/src/interface/theme_icon_badge.cpp +++ b/cockatrice/src/interface/theme_icon_badge.cpp @@ -46,16 +46,16 @@ DiscFill discFill(const QColor &tone) namespace ThemeIconBadge { -QColor glyphColor(const QColor &tone) +QColor glyphColor(const QColor &tone, ThemeIconFill::Scheme scheme) { // The badge carries the ink the pre-sticker badge did: white on the light // scheme, and the tone darkened to an outline-dark ink on the dark scheme. - // The shared saturated disc is light enough there that white sits near - // 2.7:1 at the top, so the dark ink is the deliberate choice rather than a - // contrast measurement that a palette tweak could flip. Deriving it from - // the tone keeps themes in control. - return ThemeIconFill::isDarkSchemeTone(tone) ? ThemeIconFill::saturated(tone).darker(GLYPH_DARKER) - : QColor(Qt::white); + // The lifted disc is light enough there that white sits near 2.7:1 at the + // top, so the dark ink is the deliberate choice rather than a contrast + // measurement that a palette tweak could flip. Deriving it from the tone + // keeps themes in control, and the scheme is explicit because an authored + // quiet level is a dark tone on the dark scheme too. + return scheme == ThemeIconFill::Scheme::Dark ? tone.darker(GLYPH_DARKER) : QColor(Qt::white); } QSize glyphSize(const QSize &pixelSize) @@ -70,7 +70,7 @@ QSize glyphSize(const QSize &pixelSize) return QSize(glyphSide, glyphSide); } -QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize) +QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize, ThemeIconFill::Scheme scheme) { if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) { return {}; @@ -100,12 +100,12 @@ QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixe // The ring closes the disc: its outer edge touches the canvas, its inner // edge is the disc boundary, so the two meet without a seam. painter.setBrush(Qt::NoBrush); - painter.setPen(QPen(ThemeIconFill::saturated(tone).darker(RING_DARKER), ringWidth)); + painter.setPen(QPen(tone.darker(RING_DARKER), ringWidth)); const qreal ringRadius = (side - ringWidth) / 2.0; painter.drawEllipse(discRect.center(), ringRadius, ringRadius); const QByteArray glyphSvg = resolveSvgStrokeWidth( - resolveSvgColorSentinel(svg, glyphColor(tone).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH); + resolveSvgColorSentinel(svg, glyphColor(tone, scheme).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH); QSvgRenderer renderer(glyphSvg); if (renderer.isValid()) { const QSize size = glyphSize(pixelSize); diff --git a/cockatrice/src/interface/theme_icon_badge.h b/cockatrice/src/interface/theme_icon_badge.h index 412082377..f1c392d02 100644 --- a/cockatrice/src/interface/theme_icon_badge.h +++ b/cockatrice/src/interface/theme_icon_badge.h @@ -6,6 +6,8 @@ #ifndef THEME_ICON_BADGE_H #define THEME_ICON_BADGE_H +#include "theme_icon_fill.h" + #include #include #include @@ -34,13 +36,17 @@ namespace ThemeIconBadge /** * @brief Glyph color for a badge disc. * - * White on the light scheme, the saturated tone darkened to an outline-dark ink + * White on the light scheme, the authored tone darkened to an outline-dark ink * on the dark scheme. The dark-scheme tones fill the disc light enough that * white would sit near 2.7:1 at its top, so that scheme keeps the darkened ink - * the badge had before the filled treatments shared a fill. The dark ink is - * derived from the tone, so the theme keeps control of both ends. + * the badge had before the filled treatments shared a fill. The scheme is passed + * in rather than inferred from the tone, because an authored quiet level is a + * dark color on the dark scheme as well. + * + * @param tone Disc color, already resolved for the icon mode. + * @param scheme Scheme the badge is painted for. */ -QColor glyphColor(const QColor &tone); +QColor glyphColor(const QColor &tone, ThemeIconFill::Scheme scheme); /** @brief Size of the engraved glyph for a badge of @p pixelSize, in device pixels. */ QSize glyphSize(const QSize &pixelSize); @@ -63,9 +69,10 @@ QSize glyphSize(const QSize &pixelSize); * @param tone Disc color, already resolved for the icon mode. * @param pixelSize Canvas size in device pixels; a non-square canvas centers * the badge and leaves the remaining area transparent. + * @param scheme Scheme the badge is painted for; selects the glyph ink. * @return The composited badge, or a null pixmap for empty or invalid input. */ -QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize); +QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize, ThemeIconFill::Scheme scheme); } // namespace ThemeIconBadge #endif // THEME_ICON_BADGE_H diff --git a/cockatrice/src/interface/theme_icon_fill.cpp b/cockatrice/src/interface/theme_icon_fill.cpp index 7615fb4fa..79690acea 100644 --- a/cockatrice/src/interface/theme_icon_fill.cpp +++ b/cockatrice/src/interface/theme_icon_fill.cpp @@ -1,61 +1,22 @@ #include "theme_icon_fill.h" -#include - namespace { -// Semantic tones are tuned for text. The dark-scheme ones are deliberately -// desaturated, which reads washed out when the same color fills a whole shape, -// so the fill gives its tone a saturation floor. -constexpr int MIN_SATURATION = 170; -// A tone this light belongs to the dark scheme; filling a glyph with it at full -// value is what looks pale, so its gradient is pulled down instead of up. -constexpr int LIGHT_TONE_VALUE = 200; -constexpr int MIN_VALUE = 200; +// The bottom end of every filled treatment. The authored tone is the top, so a +// single darkening keeps the emphasis level the palette author picked and still +// gives the shape the lit-from-above depth the pre-#7595 art had. constexpr int GRADIENT_BOTTOM_DARKER = 140; -constexpr int DARK_SCHEME_TOP_DARKER = 120; -constexpr int DARK_SCHEME_BOTTOM_DARKER = 170; -} // namespace - -namespace -{ -/** @brief Raises a tone's saturation, and optionally its value, for a filled shape. */ -QColor boost(const QColor &color, int minValue) -{ - if (color.hue() < 0 || (color.hsvSaturation() >= MIN_SATURATION && color.value() >= minValue)) { - return color; - } - QColor result = color; - result.setHsv(color.hue(), qMax(color.hsvSaturation(), MIN_SATURATION), qMax(color.value(), minValue)); - // Return RGB so callers comparing or caching the tone do not see a spec - // change for an unchanged color. - return result.toRgb(); -} } // namespace namespace ThemeIconFill { -QColor saturated(const QColor &tone) -{ - return boost(tone, 0); -} - -bool isDarkSchemeTone(const QColor &tone) -{ - return tone.value() >= LIGHT_TONE_VALUE; -} - QColor topColor(const QColor &tone) { - if (isDarkSchemeTone(tone)) { - return boost(tone, 0).darker(DARK_SCHEME_TOP_DARKER); - } - return boost(tone, MIN_VALUE); + return tone; } QColor bottomColor(const QColor &tone) { - const int darker = isDarkSchemeTone(tone) ? DARK_SCHEME_BOTTOM_DARKER : GRADIENT_BOTTOM_DARKER; - return boost(tone, 0).darker(darker); + return tone.darker(GRADIENT_BOTTOM_DARKER); } } // namespace ThemeIconFill diff --git a/cockatrice/src/interface/theme_icon_fill.h b/cockatrice/src/interface/theme_icon_fill.h index 4efcc136e..c0e8ee6eb 100644 --- a/cockatrice/src/interface/theme_icon_fill.h +++ b/cockatrice/src/interface/theme_icon_fill.h @@ -12,50 +12,41 @@ * @brief Picks the two colors of a filled treatment's vertical gradient. * * The pre-#7595 icons were filled with a lighter top over a darker bottom. The - * semantic tones are tuned for text, not for filling a whole shape; the - * dark-scheme tones in particular are desaturated and read washed out once a - * disc or a glyph carries them. The badge and the sticker share one fill rule so - * they sit next to each other with the same green or red. + * badge and the sticker share one fill rule so they sit next to each other with + * the same green or red. * - * The rule needs no scheme switch: a tone whose value is already high is a - * dark-scheme tone, so its gradient is pulled down. Filling a shape with a tone - * that light is what reads pale. + * The authored tone is the top of that gradient: the palette author stores one + * color per emphasis level, so the treatment only adds depth by darkening the + * bottom. Hue, saturation and value all stay the author's, so the rendered + * ladder is the authored ladder on every scheme. */ namespace ThemeIconFill { /** - * @brief Returns the tone with a saturation floor, for a filled shape. + * @brief Which scheme a treatment is painted for. * - * The hue and value are left alone, so a light-scheme tone stays as dark as the - * line glyph next to it, and an achromatic tone stays gray. - * - * @param tone Glyph color the treatment is rendered with. - * @return The tone, saturated enough to carry a whole shape. + * The scheme is a property of the surface, not of the tone: an authored quiet + * level is deliberately darker than its secondary level on the dark scheme too, + * so it cannot be inferred from the tone's value. Callers know the surface + * palette and pass it explicitly. */ -QColor saturated(const QColor &tone); - -/** - * @brief True when @p tone is a light tone, as the dark scheme's palette is. - * - * The fill branches on this: a tone this light is darkened for a filled shape, - * and the badge keeps its darkened ink on it instead of the light scheme's - * white glyph. - * - * @param tone Glyph color the treatment is rendered with. - */ -bool isDarkSchemeTone(const QColor &tone); +enum class Scheme +{ + Light, + Dark, +}; /** * @brief Returns the top color of the fill gradient. * @param tone Glyph color the treatment is rendered with. - * @return The lighter end of the gradient. + * @return The authored tone, unchanged. */ QColor topColor(const QColor &tone); /** * @brief Returns the bottom color of the fill gradient. * @param tone Glyph color the treatment is rendered with. - * @return The darker end of the gradient. + * @return The authored tone, darkened for depth. */ QColor bottomColor(const QColor &tone); } // namespace ThemeIconFill diff --git a/cockatrice/src/interface/theme_icon_ink.cpp b/cockatrice/src/interface/theme_icon_ink.cpp index 1b0748091..7ed458f70 100644 --- a/cockatrice/src/interface/theme_icon_ink.cpp +++ b/cockatrice/src/interface/theme_icon_ink.cpp @@ -12,20 +12,40 @@ namespace // accent fill. Every shipped theme accents in blue or violet, so the borrowed hue // stays neutral chrome instead of implying a state. constexpr double ACCENT_MIX = 0.70; +// The Positive family owns green and the Negative family owns red. An accent in +// either band is a semantic color the theme did not mean as chrome, so the +// neutral glyph keeps the accent's intensity but not its hue. +constexpr int SEMANTIC_GREEN_START = 80; +constexpr int SEMANTIC_GREEN_END = 165; +constexpr int SEMANTIC_RED_START = 340; +constexpr int SEMANTIC_RED_END = 25; +// Slate blue: the chrome hue every shipped theme already leans toward, and the +// least likely to be mistaken for a state. +constexpr int NEUTRAL_CHROME_HUE = 210; // An icon that labels a control is carrying text; see ui-assets.md. constexpr double LABEL_CONTRAST_FLOOR = 4.5; -// A primary glyph has to out-read the default one on both schemes. The accent is -// a mid-tone: on a light surface it is *lighter* than the neutral ink, and on a -// dark surface it is *darker*, so neither "the accent as-is" nor "a contrast -// floor shared with the default" keeps the primary above the secondary. A 7:1 -// target does: it darkens the accent on light schemes and lightens it on dark -// ones, so the primary level always out-contrasts the default level. -constexpr double PRIMARY_CONTRAST = 7.0; -// A quiet glyph is blended this far into its surface. It has to stay findable -// without competing with the actions beside it. -constexpr double QUIET_BLEND = 0.55; -// WCAG 2.2 SC 1.4.11, the floor for a non-text element that carries meaning. -constexpr double QUIET_CONTRAST_FLOOR = 3.0; +// A primary glyph is the most chromatic member of its family. Contrast cannot +// order the ladder: on a dark surface the highest-contrast tone is the palest +// one, so a contrast target makes the primary look like a washed-out secondary +// and the ladder reads inverted. Primary instead raises the saturation by this +// many HSV points over the secondary and then solves the value back to the text +// floor, so it is louder in color at the same legibility. +constexpr int PRIMARY_SATURATION_STEP = 64; +// Quiet recedes by carrying less of the family's chroma, not by dropping closer +// to the surface: the disabled level owns the low-contrast end. It keeps this +// share of the secondary level's saturation. +constexpr int QUIET_SATURATION_PERCENT = 45; +// The quiet level shares the text floor with the other two levels. +constexpr double QUIET_CONTRAST_FLOOR = LABEL_CONTRAST_FLOOR; +// A disabled glyph recedes past the quiet level, so it is blended this much +// further into the surface... +constexpr double DISABLED_BLEND = 0.55; +// ...and then held to a floor below the quiet one so it stays findable. +constexpr double DISABLED_CONTRAST_FLOOR = 2.5; +// 8-bit quantization can drop a solved color just under the ratio it was solved +// for, so every search aims this much above the floor. Without it a palette +// value generated at exactly 4.5:1 measures 4.45:1 after the round trip. +constexpr double CONTRAST_ROUNDING_MARGIN = 0.10; // Binary-search depth for reachContrast(). 24 halvings resolve to well under // one 8-bit channel step, so the result is stable across platforms. constexpr int CONTRAST_SEARCH_STEPS = 24; @@ -59,18 +79,18 @@ QColor blend(const QColor &color, const QColor &target, double amount) QColor reachContrast(const QColor &color, const QColor &background, double minRatio) { - if (contrastRatio(color, background) >= minRatio) { + const double target = minRatio + CONTRAST_ROUNDING_MARGIN; + if (contrastRatio(color, background) >= target) { return color; } - // Move away from the background: toward whichever pole actually reaches - // further. Black and white cross at a luminance of about 0.18, not 0.5, so - // the midpoint test would pick white on a surface where black is far more - // legible -- and could drag an ink that already cleared the floor to white. - // The hue is kept by interpolating, not by replacing. - const QColor black(Qt::black); - const QColor white(Qt::white); - const QColor pole = contrastRatio(black, background) >= contrastRatio(white, background) ? black : white; - if (contrastRatio(pole, background) < minRatio) { + // Move toward whichever pole the background leaves room for. A medium-light + // surface is lighter than mid-gray but still too light to reach a text floor + // with white, so "light background means move toward black" is the wrong + // test: compare what each pole can actually reach. + const double blackRatio = contrastRatio(QColor(Qt::black), background); + const double whiteRatio = contrastRatio(QColor(Qt::white), background); + const QColor pole = blackRatio >= whiteRatio ? QColor(Qt::black) : QColor(Qt::white); + if (qMax(blackRatio, whiteRatio) < target) { return pole; } @@ -90,9 +110,39 @@ QColor reachContrast(const QColor &color, const QColor &background, double minRa return blend(color, pole, high); } +bool isSemanticHue(int hue) +{ + if (hue < 0) { + return false; + } + return (hue >= SEMANTIC_GREEN_START && hue <= SEMANTIC_GREEN_END) || hue >= SEMANTIC_RED_START || + hue <= SEMANTIC_RED_END; +} + +int neutralChromeHue() +{ + return NEUTRAL_CHROME_HUE; +} + +QColor harderSurface(const QColor &first, const QColor &second, const QColor &ink) +{ + const double inkLuminance = relativeLuminance(ink); + const auto distance = [inkLuminance](const QColor &surface) { + return std::abs(relativeLuminance(surface) - inkLuminance); + }; + return distance(first) <= distance(second) ? first : second; +} + QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix) { - return reachContrast(blend(text, accent, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb(); + // A green or red accent is a Positive or Negative color, so borrowing its + // hue would paint neutral chrome the same color as a semantic state. Keep + // the accent's intensity, replace its hue with the chrome hue. + QColor source = accent; + if (isSemanticHue(accent.hue())) { + source = QColor::fromHsv(NEUTRAL_CHROME_HUE, accent.hsvSaturation(), qMax(accent.value(), 128)); + } + return reachContrast(blend(text, source, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb(); } double defaultAccentMix() @@ -105,27 +155,141 @@ double labelContrastFloor() return LABEL_CONTRAST_FLOOR; } +namespace +{ +/** @brief Whether black reaches a higher contrast against @p surface than white. */ +bool darkerPoleWins(const QColor &surface) +{ + return ThemeIconInk::contrastRatio(QColor(Qt::black), surface) >= + ThemeIconInk::contrastRatio(QColor(Qt::white), surface); +} + +/** @brief The luminance a color needs to reach @p ratio against @p surface. */ +double targetLuminance(const QColor &surface, double ratio) +{ + const double surfaceLuminance = ThemeIconInk::relativeLuminance(surface); + return darkerPoleWins(surface) ? (surfaceLuminance + 0.05) / ratio - 0.05 + : (surfaceLuminance + 0.05) * ratio - 0.05; +} + +/** + * @brief The value that puts @p hue at @p saturation on @p surface's floor. + * + * Luminance is monotone in HSV value at a fixed hue and saturation, so a + * bisection finds the boundary: the closest tone to the surface that still + * clears the floor, on either scheme. The direction depends on the surface: + * toward black (a light surface) the floor is a luminance ceiling, so the + * search keeps the highest passing value; toward white (a dark surface) it is a + * floor, so the search keeps the lowest passing value. Returns an invalid color + * when the tone cannot reach the floor at that saturation at all. + */ +QColor solveValue(int hue, int saturation, const QColor &surface, double floor) +{ + const bool towardBlack = darkerPoleWins(surface); + const double target = targetLuminance(surface, floor + CONTRAST_ROUNDING_MARGIN); + // Toward black a tone is legible at or below the target luminance, toward + // white at or above it. Testing the wrong side is what let a saturated + // violet at full value look "too dark" on a light surface and fall back to + // the unchanged secondary. + const auto passes = [hue, saturation, target, towardBlack](int value) { + const double luminance = ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, value)); + return towardBlack ? luminance <= target : luminance >= target; + }; + + if (passes(255)) { + // Toward black, full value is the palest tone: if even that clears the + // floor, the whole ramp does and the surface wants the authored tone. + // Toward white it is the palest tone but also the most legible one, so + // the search still starts from the failing end. + if (towardBlack) { + return QColor::fromHsv(hue, saturation, 255).toRgb(); + } + } else if (!towardBlack) { + // Toward white the floor needs the palest tone; no darker value can + // reach it at this saturation. + return {}; + } + + int passing = towardBlack ? 0 : 255; + int failing = towardBlack ? 255 : 0; + for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) { + const int middle = (passing + failing) / 2; + if (passes(middle)) { + passing = middle; + } else { + failing = middle; + } + } + return QColor::fromHsv(hue, saturation, passing).toRgb(); +} + +/** + * @brief Raises a color's saturation, then solves its value for the floor. + * + * Saturation is raised before the value is solved, because reaching a contrast + * floor by adding white is what desaturates a tone in the first place. If the + * hue cannot reach the floor at the raised saturation inside the gamut, the step + * shrinks until it can, so an already-vivid tone keeps what it has. A gray has + * no saturation to raise and falls back to the contrast search. + */ +QColor chromaticStep(const QColor &color, const QColor &surface, double floor, int stepPoints) +{ + if (color.hue() < 0) { + return ThemeIconInk::reachContrast(color, surface, floor); + } + + const int hue = color.hue(); + const int secondary = color.hsvSaturation(); + for (int saturation = qMin(255, secondary + stepPoints); saturation > secondary; --saturation) { + const QColor solved = solveValue(hue, saturation, surface, floor); + if (solved.isValid()) { + return solved; + } + } + return ThemeIconInk::reachContrast(color, surface, floor); +} + +/** + * @brief Mutes a color's saturation, then solves its value for the floor. + * + * The mirror of chromaticStep: quiet stays on the same floor as the other + * levels and recedes by carrying less of the family's chroma, so it cannot drop + * below the disabled level's contrast by construction. A gray has no saturation + * to mute and falls back to the contrast search. + */ +QColor mutedStep(const QColor &color, const QColor &surface, double floor, int percent) +{ + if (color.hue() < 0) { + return ThemeIconInk::reachContrast(color, surface, floor); + } + + const int saturation = qBound(0, qRound(color.hsvSaturation() * percent / 100.0), 255); + const QColor solved = solveValue(color.hue(), saturation, surface, floor); + return solved.isValid() ? solved : ThemeIconInk::reachContrast(color, surface, floor); +} +} // namespace + QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis) { switch (emphasis) { case IconEmphasis::Primary: - return reachContrast(color, surface, PRIMARY_CONTRAST).toRgb(); + return chromaticStep(color, surface, LABEL_CONTRAST_FLOOR, PRIMARY_SATURATION_STEP).toRgb(); case IconEmphasis::Quiet: - return reachContrast(blend(color, surface, QUIET_BLEND), surface, QUIET_CONTRAST_FLOOR).toRgb(); + return mutedStep(color, surface, QUIET_CONTRAST_FLOOR, QUIET_SATURATION_PERCENT).toRgb(); case IconEmphasis::Secondary: break; } return color; } -double primaryContrastFloor() +QColor disabledInk(const QColor &quiet, const QColor &surface) { - return PRIMARY_CONTRAST; + return reachContrast(blend(quiet, surface, DISABLED_BLEND), surface, DISABLED_CONTRAST_FLOOR).toRgb(); } -double quietBlend() +double primaryContrastFloor() { - return QUIET_BLEND; + return LABEL_CONTRAST_FLOOR; } double quietContrastFloor() diff --git a/cockatrice/src/interface/theme_icon_ink.h b/cockatrice/src/interface/theme_icon_ink.h index 6ba90a239..dfa585722 100644 --- a/cockatrice/src/interface/theme_icon_ink.h +++ b/cockatrice/src/interface/theme_icon_ink.h @@ -24,7 +24,9 @@ * on. The chroma is what makes it read as an icon rather than as leftover text; * the floor keeps a theme with a weak text color from producing a glyph that is * not legible. Neither step invents a color: both are derived from the palette - * the theme already ships. + * the theme already ships. An unauthored neutral family stops at the floor: the + * live platform palette is the only source that knows the actual scheme, so a + * chroma pinned in a palette file could belong to the other one. * * The rule only applies to the neutral label roles. A semantic tint * (`AppColor::Positive`, `QPalette::Highlight`, `QPalette::Link`) is a hue the @@ -32,6 +34,27 @@ */ namespace ThemeIconInk { +/** + * @brief Whether @p hue belongs to the band the Positive or Negative family owns. + * + * A green accent is a Positive color and a red accent is a Negative one, whether + * or not the theme means it that way. Neutral chrome must not borrow either hue, + * or "settings" and "add" are painted the same color. + */ +bool isSemanticHue(int hue); + +/** @brief The hue neutral chrome falls back to when the accent is semantic. */ +int neutralChromeHue(); + +/** + * @brief The harder of two surfaces to keep an ink legible on. + * + * Contrast is monotone in the luminance distance between an ink and its + * surface, so the surface nearest @p ink is the one it reads worst on. Choosing + * that one keeps a single authored ink above the floor on both. + */ +QColor harderSurface(const QColor &first, const QColor &second, const QColor &ink); + /** * @brief WCAG relative luminance of an opaque color. */ @@ -54,9 +77,10 @@ QColor blend(const QColor &color, const QColor &target, double amount); /** * @brief Pushes @p color away from @p background until it reaches @p minRatio. * - * Moves @p color toward black on a light background and toward white on a dark - * one, so the hue survives the adjustment. Returns @p color unchanged when it - * already meets the floor, which is the common case for a shipped palette. + * Moves @p color toward whichever pole, black or white, actually reaches + * @p minRatio against @p background, so the hue survives the adjustment. + * Returns @p color unchanged when it already meets the floor, which is the + * common case for a shipped palette. * * @param color Color to adjust. * @param background Surface the color is drawn on. @@ -82,7 +106,9 @@ double defaultAccentMix(); * @param accent The theme accent, normally `QPalette::Highlight`. * @param accentMix How much of the accent color to borrow. Defaults to * defaultAccentMix(); previews and tests sweep it to compare - * strengths, production callers do not pass it. + * strengths. Production passes 0 for an unauthored neutral + * family, where the live platform palette is the only source + * that knows the actual scheme. * @return The ink to resolve `currentColor` to, as an RGB color. */ QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix = defaultAccentMix()); @@ -99,13 +125,12 @@ double labelContrastFloor(); /** * @brief Applies an emphasis level to an already-hued glyph color. * - * Secondary keeps the hue untouched, so it is the no-op the default needs. - * Primary pushes the color to a higher floor than the default, because the - * accent is a mid-tone: on a light scheme it is lighter than the neutral ink and - * on a dark one darker, so only a stronger contrast target keeps the primary - * level above the secondary on both. Quiet - * blends the color toward the surface it sits on and stops at the non-text - * legibility floor, so a labelling glyph recedes without disappearing. + * The three levels share one legibility floor and differ by chroma, because + * contrast cannot order them: on a dark surface the highest-contrast tone is the + * palest one, so a contrast target inverts the ladder. Primary is the most + * chromatic member of its family, secondary keeps the color untouched (the + * no-op the default needs), and quiet mutes the color to a fraction of its + * saturation before solving it back to the same floor. * * @param color Hue-resolved glyph color. * @param surface Surface the glyph is drawn on. @@ -114,13 +139,27 @@ double labelContrastFloor(); */ QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis); -/** @brief Contrast floor the primary level reaches, so it out-reads the default. */ +/** + * @brief The ink a disabled glyph is painted with, given the quiet level. + * + * A disabled glyph is the quiet ink receded further toward the surface and held + * to a floor below the quiet one. The palette's own disabled text is a fixed + * gray whose contrast varies with the surface, so on the darkest themes it + * out-reads the quiet level and an enabled glyph looks more disabled than a + * disabled one. Deriving the disabled ink from the quiet ink makes that order + * structural instead of incidental. + */ +QColor disabledInk(const QColor &quiet, const QColor &surface); + +/** + * @brief Contrast floor the primary level reaches, so it stays legible. + * + * Primary is ordered by chroma, not contrast, so it shares the text floor + * rather than clearing a higher one. + */ double primaryContrastFloor(); -/** @brief How far a quiet glyph is blended into its surface. */ -double quietBlend(); - -/** @brief Contrast floor a quiet glyph keeps, the WCAG non-text minimum. */ +/** @brief Contrast floor the quiet level reaches, shared with the other levels. */ double quietContrastFloor(); } // namespace ThemeIconInk diff --git a/cockatrice/src/interface/theme_icon_sticker.cpp b/cockatrice/src/interface/theme_icon_sticker.cpp index c5f86c7ac..135ba71b8 100644 --- a/cockatrice/src/interface/theme_icon_sticker.cpp +++ b/cockatrice/src/interface/theme_icon_sticker.cpp @@ -30,7 +30,7 @@ namespace ThemeIconSticker { QColor outlineColor(const QColor &tone) { - return ThemeIconFill::saturated(tone).darker(OUTLINE_DARKER); + return tone.darker(OUTLINE_DARKER); } QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize) diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 1cf62ae8b..1ad0309b2 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,37 @@ 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 that deliberately leaves the neutral family + // unauthored (System). No accent chroma is borrowed here: the + // palette file that would carry it cannot know the platform scheme, + // so neutral ink is the live WindowText held to the label floor. + // 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 text = palette.color(QPalette::Active, QPalette::WindowText); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), text); + const QColor ink = ThemeIconInk::glyphInk(text, surface, accent, 0.0); + switch (role) { + case AppColor::IconStrong: + return ThemeIconInk::forEmphasis(ink, 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..419859844 100644 --- a/cockatrice/themes/Fabric/palette-default-dark.toml +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -71,6 +71,12 @@ Accent = #4a82e6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 -Positive = #6dd58c -Negative = #ff8a80 - +Icon = #7ca0e5 +IconStrong = #6a9dff +IconSoft = #93a1ba +Positive = #5bb174 +PositiveStrong = #30b758 +PositiveSoft = #84a98f +Negative = #ed8077 +NegativeStrong = #ff7367 +NegativeSoft = #be9794 diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml index a958b39d1..95973457f 100644 --- a/cockatrice/themes/Fabric/palette-default-light.toml +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -71,6 +71,12 @@ Accent = #2f6fd6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 -Positive = #1b7f3b -Negative = #b3261e - +Icon = #2f58ac +IconStrong = #054ddf +IconSoft = #505c76 +Positive = #166931 +PositiveStrong = #006b22 +PositiveSoft = #40634b +Negative = #b0251d +NegativeStrong = #ba0a00 +NegativeSoft = #7c504d diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index 8be52dc5f..adc992a89 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -69,7 +69,14 @@ Accent = #ff1e1e1e [AppColors] -AccentStrong = #ff139740 -AccentSoft = #ffc9fd62 -Positive = #6dd58c -Negative = #ff8a80 +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 +Icon = #74ace4 +IconStrong = #58acff +IconSoft = #95aabf +Positive = #5fb97a +PositiveStrong = #32bf5c +PositiveSoft = #8ab196 +Negative = #f8867c +NegativeStrong = #ff8075 +NegativeSoft = #c79e9b diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 9548af916..5e2caa4b8 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -69,7 +69,14 @@ Accent = #fff0f0f0 [AppColors] -AccentStrong = #ff139740 -AccentSoft = #ffc9fd62 -Positive = #1b7f3b -Negative = #b3261e +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 +Icon = #196dc2 +IconStrong = #006ad4 +IconSoft = #546f8a +Positive = #1b7d3a +PositiveStrong = #007e28 +PositiveSoft = #4c765a +Negative = #cf2c23 +NegativeStrong = #da0c00 +NegativeSoft = #935f5c diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml index 26bee5be3..6782bb7c4 100644 --- a/cockatrice/themes/Leather/palette-default-dark.toml +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -71,6 +71,12 @@ Accent = #c9995a [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f -Positive = #6dd58c -Negative = #ff8a80 - +Icon = #bd8d4a +IconStrong = #d0851e +IconSoft = #a79479 +Positive = #55a66d +PositiveStrong = #2dab52 +PositiveSoft = #7c9f86 +Negative = #df7970 +NegativeStrong = #ff5e50 +NegativeSoft = #b38e8b diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml index 0219fce8e..e405881cd 100644 --- a/cockatrice/themes/Leather/palette-default-light.toml +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -71,6 +71,12 @@ Accent = #a5712f [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f -Positive = #1b7f3b -Negative = #b3261e - +Icon = #725025 +IconStrong = #7e4b09 +IconSoft = #625544 +Positive = #15642e +PositiveStrong = #006621 +PositiveSoft = #3d5f48 +Negative = #a7231c +NegativeStrong = #b10a00 +NegativeSoft = #764c4a diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml index e013e3602..de36f449a 100644 --- a/cockatrice/themes/Plasma/palette-default-dark.toml +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -71,6 +71,12 @@ Accent = #6a4df0 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 -Positive = #6dd58c -Negative = #ff8a80 - +Icon = #9380eb +IconStrong = #927aff +IconSoft = #928cb0 +Positive = #519f69 +PositiveStrong = #2ba44f +PositiveSoft = #779881 +Negative = #d5736b +NegativeStrong = #ff4f40 +NegativeSoft = #ab8885 diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml index 71d86917c..b93002e25 100644 --- a/cockatrice/themes/Plasma/palette-default-light.toml +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -71,6 +71,12 @@ Accent = #5a3ee2 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 -Positive = #1b7f3b -Negative = #b3261e - +Icon = #5338ce +IconStrong = #4118fe +IconSoft = #585078 +Positive = #15622e +PositiveStrong = #006420 +PositiveSoft = #3c5d47 +Negative = #a4231b +NegativeStrong = #ae0900 +NegativeSoft = #744b48 diff --git a/cockatrice/themes/System/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml index 9f945fa30..b26bbd268 100644 --- a/cockatrice/themes/System/palette-default-dark.toml +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -3,8 +3,17 @@ # 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. +# The neutral icon family is deliberately not authored: the palette file is +# picked from the theme's scheme, which on Qt < 6.5 is always light and on newer +# Qt can be unknown, so a fixed black-or-white ladder would be invisible on a +# platform palette of the other scheme. Neutral ink derives from the live +# WindowText instead. [AppColors] -AccentStrong = #ff148c3c -AccentSoft = #ff78c850 -Positive = #ff6dd58c -Negative = #ffff8a80 +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 +Positive = #ff6dd58c +PositiveStrong = #ff32c05c +PositiveSoft = #ff8ab196 +Negative = #ffff8a80 +NegativeStrong = #ffff8378 +NegativeSoft = #ffc79e9b diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml index 32cf7845c..6d6d7ebf4 100644 --- a/cockatrice/themes/System/palette-default-light.toml +++ b/cockatrice/themes/System/palette-default-light.toml @@ -3,8 +3,17 @@ # 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. +# The neutral icon family is deliberately not authored: the palette file is +# picked from the theme's scheme, which on Qt < 6.5 is always light and on newer +# Qt can be unknown, so a fixed black-or-white ladder would be invisible on a +# platform palette of the other scheme. Neutral ink derives from the live +# WindowText instead. [AppColors] -AccentStrong = #ff148c3c -AccentSoft = #ff78c850 -Positive = #ff1b7f3b -Negative = #ffb3261e +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 +Positive = #ff1a7d3a +PositiveStrong = #ff007e28 +PositiveSoft = #ff4c7659 +Negative = #ffb3261e +NegativeStrong = #ffda0b00 +NegativeSoft = #ff935f5c diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml index 56d3e2c4c..1fb24eb65 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-dark.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -71,6 +71,12 @@ Accent = #8fa6c0 [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 -Positive = #6dd58c -Negative = #ff8a80 - +Icon = #8493a2 +IconStrong = #6b95bf +IconSoft = #8c9399 +Positive = #52a16a +PositiveStrong = #2ba650 +PositiveSoft = #789a82 +Negative = #d8756c +NegativeStrong = #ff5344 +NegativeSoft = #ad8a86 diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml index 04461013b..1599d71fc 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-light.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -71,6 +71,12 @@ Accent = #54687e [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 -Positive = #1b7f3b -Negative = #b3261e - +Icon = #495a6e +IconStrong = #375a84 +IconSoft = #525961 +Positive = #16662f +PositiveStrong = #006721 +PositiveSoft = #3e6049 +Negative = #aa241c +NegativeStrong = #b40a00 +NegativeSoft = #784d4b diff --git a/tests/theme_migration/icon_treatment_preview_test.cpp b/tests/theme_migration/icon_treatment_preview_test.cpp index 212987ec0..1d027a9e7 100644 --- a/tests/theme_migration/icon_treatment_preview_test.cpp +++ b/tests/theme_migration/icon_treatment_preview_test.cpp @@ -187,9 +187,10 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER; for (const Family &family : families) { - const QColor raw = config.appColors.value(family.role, defaultAppColor(family.role, dark)); - ASSERT_TRUE(raw.isValid()) << theme.toStdString() << " " << family.name; - const QColor tone = ThemeIconFill::saturated(raw); + // The authored role is the tone the filled shape carries; the + // treatments apply their own fill rule from it. + const QColor tone = config.appColors.value(family.role, defaultAppColor(family.role, dark)); + ASSERT_TRUE(tone.isValid()) << theme.toStdString() << " " << family.name; for (int state = 0; state < 2; ++state) { const bool isDisabled = state == 1; @@ -221,7 +222,9 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) if (treatment == 1) { return ThemeIconSticker::renderSticker(svg, ink, pixelSize); } - return ThemeIconBadge::renderBadge(svg, ink, pixelSize); + return ThemeIconBadge::renderBadge(svg, ink, pixelSize, + dark ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light); }); const qreal x = cellX + 6 + static_cast(glyphIndex) * SLOT + (SLOT - side) / 2.0; diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index f51282e0a..93203be6c 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 { @@ -79,15 +80,33 @@ int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) /** * @brief The ink the engine resolves a neutral glyph to, for @p palette. * - * Neutral glyphs are not painted with WindowText verbatim: theme_icon_ink - * borrows accent chroma and holds the result to a contrast floor. Tests that - * pin the rendered color ask the rule instead of restating it. + * Neutral glyphs are not painted with WindowText verbatim: theme_icon_ink holds + * the platform text color to a contrast floor. Tests that pin the rendered color + * ask the rule instead of restating it. No accent chroma is borrowed: this is + * the fallback an unauthored neutral family takes, so it works whatever scheme + * the platform palette actually is. */ QColor expectedNeutralInk(const QPalette &palette) { - return ThemeIconInk::glyphInk(palette.color(QPalette::Active, QPalette::WindowText), - palette.color(QPalette::Active, QPalette::Window), - palette.color(QPalette::Active, QPalette::Highlight)); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), text); + return ThemeIconInk::glyphInk(text, surface, palette.color(QPalette::Active, QPalette::Highlight), 0.0); +} + +/** + * @brief Renders a glyph at the sheet's device pixel ratio. + * + * The preview sheets paint through a painter scaled by @p dpr, so a pixmap + * rendered at logical size would be upscaled on the way into the file and read + * as blurry. Rendering at device size and tagging the pixmap keeps one source + * pixel per device pixel. + */ +template QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render) +{ + QPixmap pixmap = render(logicalSize * dpr); + pixmap.setDevicePixelRatio(dpr); + return pixmap; } /** @brief Opaque pixels that are not the given rim color, i.e. the gradient fill. */ @@ -274,17 +293,29 @@ TEST_F(ThemeIconRenderTest, AppColorTintUsesSemanticColorNotTheAccent) EXPECT_EQ(countOpaquePixels(negativePixmap, accent), 0); } -/** @brief A disabled control must not keep signalling its semantic color. */ -TEST_F(ThemeIconRenderTest, DisabledAppColorUsesDisabledForeground) +/** + * @brief A disabled glyph recedes past the quiet level, whatever the family. + * + * The palette's own disabled text is a fixed gray, so on the darkest themes it + * out-reads the quiet level and an enabled glyph looks more disabled than a + * disabled one. The disabled ink is derived from the quiet ink instead, and a + * semantic family loses its hue when disabled. + */ +TEST_F(ThemeIconRenderTest, DisabledGlyphRecedesPastTheQuietLevel) { - QPalette palette = qApp->palette(); - const QColor disabledText(QStringLiteral("#fedcba")); - palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); - qApp->setPalette(palette); + const QColor quiet = themeManager->appColor(AppColor::IconSoft); + ASSERT_TRUE(quiet.isValid()); + const QPalette palette = qApp->palette(); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), quiet); + const QColor expected = ThemeIconInk::disabledInk(quiet, surface); - const IconTint tint(AppColor::Negative); - EXPECT_EQ(tint.resolve(QIcon::Disabled), disabledText); - EXPECT_NE(tint.resolve(QIcon::Normal), disabledText); + for (const AppColor::Role role : {AppColor::Icon, AppColor::Positive, AppColor::Negative}) { + const QColor disabled = IconTint(role).resolve(QIcon::Disabled); + EXPECT_EQ(disabled, expected) << static_cast(role); + EXPECT_NE(disabled, themeManager->appColor(role)) << static_cast(role); + } + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface) - ThemeIconInk::contrastRatio(expected, surface), 1.0); } /** @brief Palette tints keep the WindowText -> HighlightedText selected swap. */ @@ -298,10 +329,16 @@ TEST_F(ThemeIconRenderTest, SelectedPaletteTintUsesHighlightedText) qApp->setPalette(palette); const IconTint tint(QPalette::WindowText); - // The normal ink is derived from the active text color, but it is icon ink - // rather than the text color itself. - EXPECT_NE(tint.resolve(QIcon::Normal), activeText); - EXPECT_EQ(tint.resolve(QIcon::Normal), expectedNeutralInk(qApp->palette())); + // The normal ink is the accent-free neutral fallback: the platform text + // color held to the label floor. A text color that already clears the floor + // is the ink verbatim, so this pins the rule and the floor, not a shift. + const QColor normal = tint.resolve(QIcon::Normal); + EXPECT_EQ(normal, expectedNeutralInk(qApp->palette())); + const QPalette resolved = qApp->palette(); + const QColor surface = ThemeIconInk::harderSurface(resolved.color(QPalette::Active, QPalette::Window), + resolved.color(QPalette::Active, QPalette::Button), + resolved.color(QPalette::Active, QPalette::WindowText)); + EXPECT_GE(ThemeIconInk::contrastRatio(normal, surface), ThemeIconInk::labelContrastFloor()); // A selected row paints on the highlight, so the ink is the palette's // highlighted text rather than the icon ink. EXPECT_EQ(tint.resolve(QIcon::Selected), selectedText); @@ -332,28 +369,24 @@ 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, and the badge + * and sticker only shape the value of that tone to give the shape depth. */ -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); } /** @brief A badge is a disc with a gradient fill, a dark ring and a contrast glyph. */ @@ -372,8 +405,8 @@ TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing) EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); EXPECT_GT(image.pixelColor(32, 32).alpha(), 200); - // The ring is the saturated tone darkened, and it must actually be painted. - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + // The ring is the authored tone darkened, and it must actually be painted. + EXPECT_GT(countOpaquePixels(pixmap, tone.darker(300)), 0); // The fill runs from a lighter top to a darker bottom. Sample left of the // check, which now reaches almost to the disc edge. @@ -385,40 +418,45 @@ TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing) EXPECT_GT(top.lightness(), bottom.lightness()); // The glyph uses the contrast color chosen for this tone. - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 0); } /** - * @brief The dark-scheme disc carries the same saturated fill as the sticker. + * @brief The dark-scheme disc keeps the authored tone and darkens it. * - * The dark tones are tuned for text and read as pale mint or salmon once they - * fill a disc, so the badge saturates its fill. The dark scheme keeps the - * darkened ink the badge had before the fill moved to the sticker ramp. + * The authored tone is the top of the gradient, so the level the palette author + * picked survives the treatment; the shape only adds depth by darkening the + * bottom. It must not re-saturate the tone: the authored saturation is the theme + * author's choice. The dark scheme keeps the darkened ink the badge had before + * the fill moved to the sticker ramp. */ -TEST_F(ThemeIconRenderTest, BadgeDarkSchemeDiscUsesTheSaturatedFill) +TEST_F(ThemeIconRenderTest, BadgeDarkSchemeKeepsTheAuthoredTone) { const QColor tone = defaultAppColor(AppColor::Positive, true); ASSERT_TRUE(tone.isValid()); ASSERT_GE(tone.value(), 200); - const QPixmap pixmap = ThemeIconBadge::renderBadge(readIconSvg("ready_start"), tone, QSize(64, 64)); + const QPixmap pixmap = + ThemeIconBadge::renderBadge(readIconSvg("ready_start"), tone, QSize(64, 64), ThemeIconFill::Scheme::Dark); ASSERT_FALSE(pixmap.isNull()); const QImage image = pixmap.toImage(); - // The disc neither keeps the pale raw tone nor the old unsaturating bottom. - EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); - EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(104)), 0); const QColor top = image.pixelColor(10, 16); const QColor bottom = image.pixelColor(10, 48); ASSERT_GT(top.alpha(), 200); ASSERT_GT(bottom.alpha(), 200); - // Antialiasing costs a point or two of saturation at the sample points. - EXPECT_GE(top.hsvSaturation(), 160); - EXPECT_GE(bottom.hsvSaturation(), 160); + // The top is a darkening of the authored tone, never a lift, and it keeps + // the authored saturation; the sample sits a little down the gradient. + EXPECT_LE(top.value(), tone.value()); + EXPECT_GT(top.value(), bottom.value()); + EXPECT_LT(bottom.value(), tone.value()); EXPECT_GT(top.lightness(), bottom.lightness()); + // Antialiasing costs a point or two of saturation at the sample points. + EXPECT_NEAR(top.hsvSaturation(), tone.hsvSaturation(), 24); + EXPECT_NEAR(bottom.hsvSaturation(), tone.hsvSaturation(), 24); // The dark scheme keeps the darkened ink, and it is actually painted. - const QColor ink = ThemeIconBadge::glyphColor(tone); + const QColor ink = ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Dark); ASSERT_NE(ink, QColor(Qt::white)); EXPECT_GT(countOpaquePixels(pixmap, ink), 0); } @@ -427,8 +465,8 @@ TEST_F(ThemeIconRenderTest, BadgeDarkSchemeDiscUsesTheSaturatedFill) * @brief The badge ink follows the scheme. * * Light-scheme tones keep the white glyph of the old art; dark-scheme tones - * keep the darkened ink, because the shared saturated disc is light enough that - * white would sit near 2.7:1 at its top. Pinning both cases stops the glyph from + * keep the darkened ink, because the lifted disc is light enough that white + * would sit near 2.7:1 at its top. Pinning both cases stops the glyph from * silently flipping back to white when the fill or the palette changes. */ TEST_F(ThemeIconRenderTest, BadgeInkFollowsTheScheme) @@ -445,7 +483,8 @@ TEST_F(ThemeIconRenderTest, BadgeInkFollowsTheScheme) for (const auto &testCase : cases) { const QColor tone = defaultAppColor(testCase.role, testCase.dark); ASSERT_TRUE(tone.isValid()); - const QColor ink = ThemeIconBadge::glyphColor(tone); + const QColor ink = ThemeIconBadge::glyphColor(tone, testCase.dark ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light); if (testCase.dark) { EXPECT_NE(ink, QColor(Qt::white)); EXPECT_LT(ink.value(), 100); @@ -488,7 +527,7 @@ TEST_F(ThemeIconRenderTest, BadgeStaysLegibleAtPlayerListSize) EXPECT_GT(image.pixelColor(7, 7).alpha(), 200); // A 1px ring on a 15px canvas antialiases into the disc and has no exact // color, so the glyph is what has to stay legible. - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 8); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 8); } /** @brief The pixmap API's badge path agrees with the engine's. */ @@ -499,8 +538,8 @@ TEST_F(ThemeIconRenderTest, SentinelSvgBadgeMatchesTheEngine) QSize(48, 48), IconStyle::Badge); ASSERT_FALSE(pixmap.isNull()); EXPECT_EQ(pixmap.size(), QSize(48, 48)); - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0); + EXPECT_GT(countOpaquePixels(pixmap, tone.darker(300)), 0); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 0); } /** @@ -577,16 +616,14 @@ TEST_F(ThemeIconRenderTest, BadgeOnAuthoredArtUsesTheAssetPath) // The authored color survives; no disc tone and no ring are painted. EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0); EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); - EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0); } /** @brief Disabled keeps the shape language but drops back to the line glyph. */ TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph) { - QPalette palette = qApp->palette(); - const QColor disabledText(QStringLiteral("#fedcba")); - palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); - qApp->setPalette(palette); + const QColor disabled = IconTint(AppColor::Icon).resolve(QIcon::Disabled); + ASSERT_TRUE(disabled.isValid()); const QColor tone = themeManager->appColor(AppColor::Positive); const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); @@ -595,10 +632,10 @@ TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph) // A filled disc at full mass would not read as unavailable, so Disabled // drops the badge: no ring, no disc, just the disabled line glyph. - EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); - EXPECT_EQ(countOpaquePixels(pixmap, disabledText.darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, disabled.darker(300)), 0); EXPECT_EQ(pixmap.toImage().pixelColor(0, 0).alpha(), 0); - EXPECT_GT(countOpaquePixels(pixmap, disabledText), 0); + EXPECT_GT(countOpaquePixels(pixmap, disabled), 0); } /** @@ -632,13 +669,13 @@ TEST_F(ThemeIconRenderTest, StickerPaintsToneFillWithDarkOutline) } /** - * @brief The dark-scheme fill is the branch the treatment exists for. + * @brief The dark-scheme fill keeps the authored tone and darkens it. * - * That tone is light and deliberately desaturated, so a plain gradient paints a - * pale mint shape. The fill has to raise its saturation and pull its value down - * instead of up. + * The authored tone is the top of the gradient, so the level the palette author + * picked survives the treatment. The treatment only adds depth by darkening the + * bottom, without re-saturating the tone. */ -TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillIsSaturatedAndDarkerThanTheTone) +TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillKeepsTheAuthoredTone) { const QColor tone = defaultAppColor(AppColor::Positive, true); ASSERT_TRUE(tone.isValid()); @@ -652,10 +689,14 @@ TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillIsSaturatedAndDarkerThanTheTone const QColor bottom = image.pixelColor(32, 50); ASSERT_GT(top.alpha(), 200); ASSERT_GT(bottom.alpha(), 200); - EXPECT_LT(top.value(), tone.value()); + // The top is a darkening of the authored tone, never a lift, and it keeps + // the authored saturation; the sample sits a little down the gradient. + EXPECT_LE(top.value(), tone.value()); + EXPECT_GT(top.value(), bottom.value()); EXPECT_LT(bottom.value(), tone.value()); EXPECT_GT(top.lightness(), bottom.lightness()); - EXPECT_GE(top.hsvSaturation(), 170); + EXPECT_NEAR(top.hsvSaturation(), tone.hsvSaturation(), 24); + EXPECT_NEAR(bottom.hsvSaturation(), tone.hsvSaturation(), 24); } /** @brief The sticker is rendered at the device-pixel size, not upscaled later. */ @@ -726,10 +767,8 @@ TEST_F(ThemeIconRenderTest, StickerOnAuthoredArtUsesTheAssetPath) /** @brief Disabled keeps the shape language but drops back to the line glyph. */ TEST_F(ThemeIconRenderTest, DisabledStickerFallsBackToLineGlyph) { - QPalette palette = qApp->palette(); - const QColor disabledText(QStringLiteral("#fedcba")); - palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); - qApp->setPalette(palette); + const QColor disabled = IconTint(AppColor::Icon).resolve(QIcon::Disabled); + ASSERT_TRUE(disabled.isValid()); const QColor tone = themeManager->appColor(AppColor::Negative); const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker); @@ -739,7 +778,7 @@ TEST_F(ThemeIconRenderTest, DisabledStickerFallsBackToLineGlyph) // No rim in the sticker tone: Disabled drops the treatment and paints the // thin disabled line glyph instead. EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0); - EXPECT_GT(countOpaquePixels(pixmap, disabledText), 0); + EXPECT_GT(countOpaquePixels(pixmap, disabled), 0); } /** @brief Line stays the default: no disc, no ring, just the recolored glyph. */ @@ -750,7 +789,7 @@ TEST_F(ThemeIconRenderTest, LineStyleStillRendersGlyphAlone) loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive).pixmap(QSize(64, 64)); ASSERT_FALSE(pixmap.isNull()); - EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0); EXPECT_GT(countOpaquePixels(pixmap, tone), 0); } @@ -836,7 +875,8 @@ TEST(IconTintPreview, WritesPngsWhenRequested) painter.drawText(QPointF(MARGIN, rowTop + 16), QString::fromLatin1(glyph.name)); for (std::size_t i = 0; i < std::size(SIZES); ++i) { const int side = SIZES[i]; - const QPixmap pixmap = renderLine(svg, tone, QSize(side, side)); + const QPixmap pixmap = renderAtDpr( + QSize(side, side), DPR, [&](const QSize &pixelSize) { return renderLine(svg, tone, pixelSize); }); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; // Bottom-align the sizes and put the caption below the 48px row. painter.drawPixmap(QPointF(x, rowTop + 54 - side), pixmap); @@ -939,8 +979,8 @@ TEST(IconStickerPreview, WritesPngsWhenRequested) const QByteArray svg = readSvg(glyph.name); ASSERT_FALSE(svg.isEmpty()) << glyph.name; // The preview must show the line glyph the engine paints, and the - // engine saturates AppColor tones before drawing them. - const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark)); + // engine renders an authored tone verbatim. + const QColor tone = defaultAppColor(glyph.role, dark); for (int row = 0; row < 2; ++row) { const bool sticker = row == 1; @@ -950,8 +990,10 @@ TEST(IconStickerPreview, WritesPngsWhenRequested) QString::fromLatin1(glyph.name)); for (std::size_t i = 0; i < std::size(SIZES); ++i) { const int side = SIZES[i]; - const QPixmap pixmap = sticker ? ThemeIconSticker::renderSticker(svg, tone, QSize(side, side)) - : renderLine(svg, tone, QSize(side, side)); + const QPixmap pixmap = renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return sticker ? ThemeIconSticker::renderSticker(svg, tone, pixelSize) + : renderLine(svg, tone, pixelSize); + }); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; painter.drawPixmap(QPointF(x, rowTop + 36 - side), pixmap); if (row == 1) { @@ -973,8 +1015,11 @@ TEST(IconStickerPreview, WritesPngsWhenRequested) ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name; const QColor tone = defaultAppColor(badgeContext[i].role, dark); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; - painter.drawPixmap(QPointF(x, y + 8), - ThemeIconBadge::renderBadge(svg, tone, QSize(CONTEXT_SIZE, CONTEXT_SIZE))); + painter.drawPixmap( + QPointF(x, y + 8), renderAtDpr(QSize(CONTEXT_SIZE, CONTEXT_SIZE), DPR, [&](const QSize &pixelSize) { + return ThemeIconBadge::renderBadge( + svg, tone, pixelSize, dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light); + })); painter.drawText(QPointF(x, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(badgeContext[i].name)); } @@ -985,18 +1030,20 @@ TEST(IconStickerPreview, WritesPngsWhenRequested) for (std::size_t i = 0; i < std::size(badgeContext); ++i) { const QByteArray svg = readSvg(badgeContext[i].name); ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name; - const QColor tone = ThemeIconFill::saturated(defaultAppColor(badgeContext[i].role, dark)); + const QColor tone = defaultAppColor(badgeContext[i].role, dark); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH * 1.5; for (int j = 0; j < 3; ++j) { const QSize side(CONTEXT_SIZE, CONTEXT_SIZE); - QPixmap pixmap; - if (j == 0) { - pixmap = renderLine(svg, tone, side); - } else if (j == 1) { - pixmap = ThemeIconBadge::renderBadge(svg, tone, side); - } else { - pixmap = ThemeIconSticker::renderSticker(svg, tone, side); - } + const QPixmap pixmap = renderAtDpr(side, DPR, [&](const QSize &pixelSize) { + if (j == 0) { + return renderLine(svg, tone, pixelSize); + } + if (j == 1) { + return ThemeIconBadge::renderBadge( + svg, tone, pixelSize, dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light); + } + return ThemeIconSticker::renderSticker(svg, tone, pixelSize); + }); const qreal px = x + j * (CONTEXT_SIZE + 16); painter.drawPixmap(QPointF(px, y + 8), pixmap); painter.drawText(QPointF(px, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(FAMILY_LABELS[j])); @@ -1091,8 +1138,8 @@ TEST(IconBadgePreview, WritesPngsWhenRequested) const QByteArray svg = readSvg(glyph.name); ASSERT_FALSE(svg.isEmpty()) << glyph.name; // The preview must show the line glyph the engine paints, and the - // engine saturates AppColor tones before drawing them. - const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark)); + // engine renders an authored tone verbatim. + const QColor tone = defaultAppColor(glyph.role, dark); for (int row = 0; row < 2; ++row) { const bool badge = row == 1; @@ -1102,8 +1149,12 @@ TEST(IconBadgePreview, WritesPngsWhenRequested) QString::fromLatin1(glyph.name)); for (std::size_t i = 0; i < std::size(SIZES); ++i) { const int side = SIZES[i]; - const QPixmap pixmap = badge ? ThemeIconBadge::renderBadge(svg, tone, QSize(side, side)) - : renderLine(svg, tone, QSize(side, side)); + const QPixmap pixmap = renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return badge ? ThemeIconBadge::renderBadge(svg, tone, pixelSize, + dark ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light) + : renderLine(svg, tone, pixelSize); + }); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; // Align all sizes on a common baseline so the weight // difference between 15px and 48px is visible. @@ -1128,9 +1179,11 @@ namespace struct InkPalette { QColor text; + QColor buttonText; QColor window; QColor button; QColor accent; + QMap roles; }; /** @@ -1146,12 +1199,37 @@ 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. The authored + // neutral-family assertions therefore cover only themes with a palette; + // System deliberately leaves its neutral family unauthored, which + // ShippedPalettesAuthorTheEmphasisLadder pins, and its semantic fallbacks + // are measured against representative platform surfaces by + // DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces. + 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)}; + return { + palette.color(QPalette::Active, QPalette::WindowText), palette.color(QPalette::Active, QPalette::ButtonText), + palette.color(QPalette::Active, QPalette::Window), 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 neutral icon family stays unauthored there so it follows + * the live platform palette; see ShippedPalettesAuthorTheEmphasisLadder. + */ +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 @@ -1171,6 +1249,67 @@ TEST(IconInk, GivesNeutralGlyphsAccentChroma) EXPECT_GE(ThemeIconInk::contrastRatio(ink, palette.window), ThemeIconInk::labelContrastFloor()); } +/** + * @brief A medium-light surface needs the pole that can actually reach the floor. + * + * #9a9a9a is lighter than mid-gray but still too light for white to reach a text + * ratio, so "light background means move toward black" is the wrong test. + */ +TEST(IconInk, ContrastFloorPicksThePoleItCanReach) +{ + const QColor background(QStringLiteral("#9a9a9a")); + ASSERT_LT(ThemeIconInk::contrastRatio(QColor(Qt::white), background), 4.5); + ASSERT_GE(ThemeIconInk::contrastRatio(QColor(Qt::black), background), 4.5); + + const QColor solved = ThemeIconInk::reachContrast(QColor(QStringLiteral("#d0d0d0")), background, 4.5); + EXPECT_GE(ThemeIconInk::contrastRatio(solved, background), 4.5); +} + +/** + * @brief Primary stays louder than secondary on a light surface. + * + * A saturated blue or violet at full value is already dark enough to clear the + * label floor on a light surface. When solveValue() read the target as a + * minimum in both directions it rejected that tone, walked the whole saturation + * ladder and fell back to the unchanged secondary, so primary was never + * distinguishable. #5a3ee2 on #efeff4 is the shipped Plasma accent's shape. + */ +TEST(IconInk, PrimaryRaisesAChromaticToneOnALightSurface) +{ + const QColor surface(QStringLiteral("#efeff4")); + const QColor violet(QStringLiteral("#5a3ee2")); + ASSERT_GE(ThemeIconInk::contrastRatio(violet, surface), ThemeIconInk::labelContrastFloor()) + << "the fixture must already be legible, so the fallback would be a no-op"; + + const QColor primary = ThemeIconInk::forEmphasis(violet, surface, IconEmphasis::Primary); + ASSERT_TRUE(primary.isValid()); + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_GT(primary.hsvSaturation(), violet.hsvSaturation()) << "primary fell back to the unchanged secondary"; + EXPECT_NEAR(primary.hue(), violet.hue(), 2); +} + +/** + * @brief A green or red accent must not tint neutral chrome. + * + * Positive owns green and Negative owns red, so a theme whose accent sits in + * either band would otherwise paint "settings" the same color as "add". + */ +TEST(IconInk, SemanticAccentDoesNotTintNeutralChrome) +{ + const QColor surface(QStringLiteral("#18202e")); + const QColor text = Qt::white; + const QColor green(QStringLiteral("#149740")); + const QColor blue(QStringLiteral("#2f6fd6")); + + ASSERT_TRUE(ThemeIconInk::isSemanticHue(green.hue())); + ASSERT_FALSE(ThemeIconInk::isSemanticHue(blue.hue())); + + const QColor greenInk = ThemeIconInk::glyphInk(text, surface, green); + EXPECT_NEAR(greenInk.hue(), ThemeIconInk::neutralChromeHue(), 2); + EXPECT_FALSE(ThemeIconInk::isSemanticHue(greenInk.hue())); + EXPECT_NEAR(ThemeIconInk::glyphInk(text, surface, blue).hue(), blue.hue(), 1); +} + TEST(IconInk, MeetsTheLabelFloorOnEveryShippedPalette) { const QString themesRoot = @@ -1334,8 +1473,14 @@ TEST(IconInkPreview, WritesPngsWhenRequested) ASSERT_FALSE(svg.isEmpty()) << GLYPHS[i]; const qreal cellOffset = static_cast(i) * (side + CELL_GAP); const qreal glyphY = rowY + (ROW_HEIGHT - side) / 2.0; - painter.drawPixmap(QPointF(oldX + cellOffset, glyphY), renderLine(svg, oldInk, QSize(side, side))); - painter.drawPixmap(QPointF(newX + cellOffset, glyphY), renderLine(svg, newInk, QSize(side, side))); + painter.drawPixmap(QPointF(oldX + cellOffset, glyphY), + renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return renderLine(svg, oldInk, pixelSize); + })); + painter.drawPixmap(QPointF(newX + cellOffset, glyphY), + renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return renderLine(svg, newInk, pixelSize); + })); } rowY += ROW_HEIGHT; } @@ -1365,97 +1510,352 @@ 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 is the most chromatic member of its family. * - * 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. + * Contrast cannot order the ladder: on a dark surface the highest-contrast tone + * is the palest one, so a contrast target makes the primary read as a washed-out + * secondary. Chroma orders the levels instead; contrast only has to stay above + * the legibility floor on every surface the icon is drawn on. */ -TEST_F(ThemeIconRenderTest, PrimaryOutReadsSecondaryOnBothSchemes) +TEST_F(ThemeIconRenderTest, PrimaryIsTheMostChromaticLevelOnBothSchemes) { 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"); + + EXPECT_GT(primary.hsvSaturation(), secondary.hsvSaturation()) << (dark ? "dark" : "light"); + for (const QColor &surface : {palette.window, palette.button}) { + 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) +/** + * @brief Quiet is the least chromatic level; it shares the legibility floor. + * + * Ordering the ladder by contrast inverted it on dark schemes and pushed quiet + * under the disabled ink. Quiet now keeps the same floor as the other levels and + * recedes by carrying less of the family's chroma, which also leaves the + * disabled level the only one below the floor. + */ +TEST_F(ThemeIconRenderTest, QuietIsTheLeastChromaticLevel) { - 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()); + EXPECT_LT(quiet.hsvSaturation(), secondary.hsvSaturation()) << (dark ? "dark" : "light"); + for (const QColor &surface : {palette.window, palette.button}) { + const double quietContrast = ThemeIconInk::contrastRatio(quiet, surface); + EXPECT_GE(quietContrast, ThemeIconInk::quietContrastFloor()) << (dark ? "dark" : "light"); + EXPECT_GE(quietContrast - ThemeIconInk::contrastRatio(ThemeIconInk::disabledInk(quiet, surface), surface), + 1.0) + << (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, so it has no surfaces to measure. + } + ++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(palette.roles.value(AppColor::IconStrong).hsvSaturation(), + palette.roles.value(AppColor::Icon).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GE(quiet, ThemeIconInk::quietContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_LT(palette.roles.value(AppColor::IconSoft).hsvSaturation(), + palette.roles.value(AppColor::Icon).hsvSaturation()) + << 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}}) { + EXPECT_GE(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface), + ThemeIconInk::quietContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_LT(palette.roles.value(soft).hsvSaturation(), palette.roles.value(base).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GT(palette.roles.value(strong).hsvSaturation(), palette.roles.value(base).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); + } + } + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; + + // The System theme ships no palette of its own, so its families have no + // authored surfaces to measure against. It nevertheless authors the + // semantic family, while the neutral family stays unauthored on purpose: + // the palette file is chosen from the theme's scheme, which is always light + // on Qt < 6.5 and can be unknown on newer Qt, so one fixed ladder would be + // invisible against a platform palette of the other scheme. + for (const bool dark : {false, true}) { + const QMap system = shippedAppColors(QStringLiteral("System"), dark); + EXPECT_TRUE(system.contains(AppColor::PositiveStrong)); + EXPECT_TRUE(system.contains(AppColor::NegativeSoft)); + for (const AppColor::Role role : {AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}) { + EXPECT_FALSE(system.contains(role)) << (dark ? "dark" : "light") << static_cast(role); + } } } -/** @brief Every emphasis keeps the selected-row swap to HighlightedText. */ +/** + * @brief Neutral chrome keeps a hue distance from the semantic families. + * + * Fusion's accent is green, so deriving neutral ink from it painted every menu + * and toolbar glyph the same color as the Positive family. The authored neutral + * family has to stay a hue a reader can tell apart from "add" and "remove". + */ +TEST(IconInk, NeutralFamilyKeepsItsDistanceFromTheSemanticFamilies) +{ + 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; + } + const QColor neutral = palette.roles.value(AppColor::Icon); + for (const AppColor::Role role : {AppColor::Positive, AppColor::Negative}) { + const QColor semantic = palette.roles.value(role); + ASSERT_TRUE(neutral.isValid() && semantic.isValid()) + << theme.toStdString() << (dark ? " dark" : " light"); + const int distance = qAbs(neutral.hue() - semantic.hue()); + EXPECT_GE(qMin(distance, 360 - distance), 30) + << theme.toStdString() << (dark ? " dark" : " light") << static_cast(role); + ++checked; + } + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; +} + +/** + * @brief Every shipped theme keeps the disabled level below the quiet level. + * + * The palette's disabled text is a fixed gray, so this is the invariant the + * derived disabled ink exists to hold; a regression here makes an enabled quiet + * glyph look unavailable. + */ +TEST(IconInk, DisabledStaysBelowQuietOnEveryShippedTheme) +{ + 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; + } + const QColor quiet = palette.roles.value(AppColor::IconSoft); + ASSERT_TRUE(quiet.isValid()) << theme.toStdString() << (dark ? " dark" : " light"); + const QColor surface = ThemeIconInk::harderSurface(palette.window, palette.button, quiet); + const QColor disabled = ThemeIconInk::disabledInk(quiet, surface); + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface) - ThemeIconInk::contrastRatio(disabled, surface), 1.0) + << theme.toStdString() << (dark ? " dark" : " light"); + + for (const QColor &anySurface : {palette.window, palette.button}) { + EXPECT_LT(ThemeIconInk::contrastRatio(disabled, anySurface), + ThemeIconInk::contrastRatio(quiet, anySurface)) + << theme.toStdString() << (dark ? " dark" : " light"); + } + ++checked; + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; +} + +/** + * @brief The System fallback family keeps the ladder on a platform palette. + * + * System authors no palette and follows the OS surfaces, so its families + * cannot be measured against its own window and button. These are the + * representative extremes of a platform palette, and the fixed values have to + * clear the same floor there that the five authored themes clear on their own + * surfaces. + */ +TEST(IconInk, DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces) +{ + struct Scheme + { + bool dark; + QColor window; + QColor button; + }; + const QList schemes = { + {false, QColor(QStringLiteral("#f0f0f0")), QColor(QStringLiteral("#f5f5f5"))}, + {true, QColor(QStringLiteral("#2b2b2b")), QColor(QStringLiteral("#3c3c3c"))}, + }; + + for (const Scheme &scheme : schemes) { + for (const auto &[base, strong, soft] : + {std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + const QColor secondary = defaultAppColor(base, scheme.dark); + const QColor primary = defaultAppColor(strong, scheme.dark); + const QColor quiet = defaultAppColor(soft, scheme.dark); + ASSERT_TRUE(secondary.isValid() && primary.isValid() && quiet.isValid()); + + for (const QColor &surface : {scheme.window, scheme.button}) { + EXPECT_GE(ThemeIconInk::contrastRatio(secondary, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface), ThemeIconInk::labelContrastFloor()); + } + EXPECT_LT(quiet.hsvSaturation(), secondary.hsvSaturation()); + EXPECT_GT(primary.hsvSaturation(), secondary.hsvSaturation()); + } + } +} + +/** + * @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); } /** @@ -1487,19 +1887,27 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) 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}; struct Family { const char *name; const char *glyphs[3]; - AppColor::Role role; - bool neutral; + AppColor::Role quiet; + AppColor::Role secondary; + AppColor::Role primary; }; 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}, + {"Neutral", {"settings", "info", "player"}, AppColor::IconSoft, AppColor::Icon, AppColor::IconStrong}, + {"Positive", + {"increment", "ready_start", "arrow_up"}, + AppColor::PositiveSoft, + AppColor::Positive, + AppColor::PositiveStrong}, + {"Negative", + {"decrement", "not_ready_start", "remove_row"}, + AppColor::NegativeSoft, + AppColor::Negative, + AppColor::NegativeStrong}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), @@ -1531,16 +1939,14 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) // before resolving tones for the sheet. ASSERT_TRUE(applyShippedPalette(theme, dark)); + // The paint path is a lookup now, so the sheet shows the authored + // rows verbatim rather than a color recomputed from the accent. 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]); + return level == 0 ? palette.roles.value(family.quiet) + : level == 1 ? palette.roles.value(family.secondary) + : palette.roles.value(family.primary); }; + const auto scheme = dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), QImage::Format_ARGB32_Premultiplied); @@ -1609,14 +2015,15 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) 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 QPixmap pixmap = renderAtDpr(size, DPR, [&](const QSize &pixelSize) { + if (treatment == 0) { + return renderLine(svg, ink, pixelSize); + } + if (treatment == 1) { + return ThemeIconSticker::renderSticker(svg, ink, pixelSize); + } + return ThemeIconBadge::renderBadge(svg, ink, pixelSize, scheme); + }); 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); @@ -1637,3 +2044,199 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) } } } + +/** + * @brief Writes the authored emphasis sheets for every shipped palette theme. + * + * The ladder is only useful if it survives the treatment a call site chooses, so + * each sheet renders the same three authored levels as line glyphs, as sticker + * fills and as badges, at the two sizes the UI uses, on the two surfaces the + * contrast targets are measured against. Each family carries three glyphs, so + * the sheet also proves the level, not just one lucky shape. A disabled row + * closes each family: it must read dimmer than quiet, and every treatment falls + * back to the line glyph there. + * + * The authored tone is the top of every filled gradient, so the sheet prints that + * tone and the darker bottom the shape actually carries. + */ +TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + 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 qreal DPR = 2.0; + constexpr int SIZES[] = {16, 24}; + constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"}; + constexpr const char *LEVEL_NAMES[] = {"Quiet", "Secondary", "Primary"}; + + struct Family + { + const char *name; + const char *glyphs[3]; + AppColor::Role quiet; + AppColor::Role secondary; + AppColor::Role primary; + }; + const QList families = { + {"Neutral", {"settings", "info", "player"}, AppColor::IconSoft, AppColor::Icon, AppColor::IconStrong}, + {"Positive", + {"increment", "ready_start", "arrow_up"}, + AppColor::PositiveSoft, + AppColor::Positive, + AppColor::PositiveStrong}, + {"Negative", + {"decrement", "not_ready_start", "remove_row"}, + AppColor::NegativeSoft, + AppColor::Negative, + AppColor::NegativeStrong}, + }; + const QStringList themes = { + QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), + QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), + }; + + const auto roleAt = [](const Family &family, int level) { + return level == 0 ? family.quiet : level == 1 ? family.secondary : family.primary; + }; + const auto schemeFor = [](bool dark) { return dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; }; + 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); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + 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()) * 4 * 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(); + const QColor quietTone = palette.roles.value(AppColor::IconSoft); + ASSERT_TRUE(quietTone.isValid()) << theme.toStdString(); + // What the engine paints for `QIcon::Disabled`: the authored quiet + // level receded past its floor, measured on the harder surface. + const QColor disabledTone = ThemeIconInk::disabledInk( + quietTone, ThemeIconInk::harderSurface(palette.window, palette.button, quietTone)); + + 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); + + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, authored emphasis across treatments") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + struct Surface + { + const char *name; + QColor background; + QColor text; + }; + const QList surfaces = { + {"Window surface", palette.window, palette.text}, + {"Button surface", palette.button, palette.buttonText}, + }; + + 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 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 < 4; ++level) { + const bool disabled = level == 3; + const QColor ink = disabled ? disabledTone : palette.roles.value(roleAt(family, level)); + const QString levelName = + disabled ? QStringLiteral("Disabled") : QString::fromLatin1(LEVEL_NAMES[level]); + ASSERT_TRUE(ink.isValid()) + << theme.toStdString() << " " << family.name << " " << levelName.toStdString(); + const QColor bottom = disabled ? ink : ThemeIconFill::bottomColor(ink); + + 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), levelName)); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + if (!disabled && bottom != ink) { + painter.drawText(QPointF(contentLeft + 82, y + 28), + QStringLiteral("bottom ") + bottom.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]; + const QPixmap pixmap = renderAtDpr(size, DPR, [&](const QSize &pixelSize) { + if (disabled || treatment == 0) { + return renderLine(svg, ink, pixelSize); + } + if (treatment == 1) { + return ThemeIconSticker::renderSticker(svg, ink, pixelSize); + } + return ThemeIconBadge::renderBadge(svg, ink, pixelSize, schemeFor(dark)); + }); + 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.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("emphasis-treatments-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + 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 7e769b1bd..3bd8be3a2 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -155,13 +155,28 @@ TEST_F(ThemeMigrationTest, IsIdempotent) TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme) { - EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1b7f3b"))); + EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1a7d3a"))); EXPECT_EQ(defaultAppColor(AppColor::Positive, true), QColor(QStringLiteral("#6dd58c"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, false), QColor(QStringLiteral("#007e28"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, true), QColor(QStringLiteral("#32c05c"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, false), QColor(QStringLiteral("#4c7659"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, true), QColor(QStringLiteral("#8ab196"))); 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("#da0b00"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, true), QColor(QStringLiteral("#ff8378"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, false), QColor(QStringLiteral("#935f5c"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, true), QColor(QStringLiteral("#c79e9b"))); + // Accent and neutral icon roles have no static default: an older theme + // written before the roles existed falls back to deriving them from its + // palette rather than 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) @@ -170,10 +185,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); @@ -182,10 +207,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