diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index 40b51bb0a..3557a9441 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -320,26 +320,43 @@ private: }; } // namespace -IconTint::IconTint(QPalette::ColorRole role) : paletteRole(role) +IconTint::IconTint(QPalette::ColorRole role, IconEmphasis _emphasis) : paletteRole(role), emphasis(_emphasis) { } -IconTint::IconTint(AppColor::Role role) : source(Source::AppColor), appRole(role) +IconTint::IconTint(AppColor::Role role, IconEmphasis _emphasis) + : source(Source::AppColor), appRole(role), emphasis(_emphasis) { } +IconTint IconTint::primary() +{ + return IconTint(QPalette::WindowText, IconEmphasis::Primary); +} + +IconTint IconTint::quiet() +{ + return IconTint(QPalette::WindowText, IconEmphasis::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 qApp->palette().color(QPalette::Disabled, QPalette::WindowText); + return palette.color(QPalette::Disabled, QPalette::WindowText); } // The three treatments paint the same tone: a line glyph, a badge disc // and a sticker fill must not disagree on how saturated the role is. // The fill treatments apply this floor themselves, so this only changes - // the line path, and only for the desaturated dark-scheme tones. - const QColor color = ThemeIconFill::saturated(themeManager->appColor(appRole)); + // 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); if (mode != QIcon::Selected) { return color; } @@ -347,13 +364,11 @@ QColor IconTint::resolve(QIcon::Mode mode) const // semantic tint when it stays legible there; otherwise the glyph would // vanish into the selection. constexpr double MIN_SELECTED_CONTRAST = 3.0; - const QColor highlight = qApp->palette().color(QPalette::Active, QPalette::Highlight); - return ThemeIconInk::contrastRatio(color, highlight) >= MIN_SELECTED_CONTRAST + return ThemeIconInk::contrastRatio(color, accent) >= MIN_SELECTED_CONTRAST ? color - : qApp->palette().color(QPalette::Active, QPalette::HighlightedText); + : palette.color(QPalette::Active, QPalette::HighlightedText); } - const QPalette &palette = qApp->palette(); const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; const QPalette::ColorRole effective = mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole; @@ -362,8 +377,10 @@ QColor IconTint::resolve(QIcon::Mode mode) const // 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)) { - return ThemeIconInk::glyphInk(color, palette.color(QPalette::Active, QPalette::Window), - palette.color(QPalette::Active, QPalette::Highlight)); + // The primary action of a surface takes the accent itself; the other + // levels stay neutral. Emphasis then sets the contrast the glyph pays. + const QColor base = emphasis == IconEmphasis::Primary ? accent : ThemeIconInk::glyphInk(color, surface, accent); + return ThemeIconInk::forEmphasis(base, surface, emphasis); } return color; } diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h index 95cfba615..716febd7a 100644 --- a/cockatrice/src/interface/theme_icon.h +++ b/cockatrice/src/interface/theme_icon.h @@ -46,19 +46,47 @@ enum class IconStyle Sticker, ///< filled glyph + dark outline, no disc }; +/** + * @brief Selects how much attention a glyph claims inside its surface. + * + * 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. + * + * 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. + */ +enum class IconEmphasis +{ + Quiet, // labels rather than acts + Secondary, // the default action weight + Primary, // the action the surface exists for +}; + /** * @brief Selects the color that replaces the `currentColor` sentinel. * * A palette role follows Qt's structural palette. An application color carries * meaning that QPalette cannot express, such as positive and negative states. - * Callers choose the role. The active theme supplies the value. + * Callers pick the role by intent; the theme owns the concrete value. Emphasis + * is part of the same decision: both answer how the glyph is inked. */ class IconTint { public: IconTint() = default; - IconTint(QPalette::ColorRole role); // implicit: existing palette-role callers - IconTint(AppColor::Role role); // implicit: semantic callers + 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. */ + static IconTint primary(); + + /** @brief A dimmed neutral glyph, for a control that labels rather than acts. */ + static IconTint quiet(); /** * @brief Resolves the concrete color for an icon mode. @@ -74,9 +102,18 @@ public: * 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. + * + * 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. */ QColor resolve(QIcon::Mode mode) const; + IconEmphasis getEmphasis() const + { + return emphasis; + } + private: enum class Source { @@ -87,6 +124,7 @@ private: Source source = Source::Palette; QPalette::ColorRole paletteRole = QPalette::WindowText; AppColor::Role appRole = AppColor::AccentStrong; + IconEmphasis emphasis = IconEmphasis::Secondary; }; /** diff --git a/cockatrice/src/interface/theme_icon_ink.cpp b/cockatrice/src/interface/theme_icon_ink.cpp index bef5862b4..7d77ef948 100644 --- a/cockatrice/src/interface/theme_icon_ink.cpp +++ b/cockatrice/src/interface/theme_icon_ink.cpp @@ -14,6 +14,18 @@ namespace constexpr double ACCENT_MIX = 0.70; // 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; // 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; @@ -87,4 +99,32 @@ double labelContrastFloor() { return LABEL_CONTRAST_FLOOR; } + +QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis) +{ + switch (emphasis) { + case IconEmphasis::Primary: + return reachContrast(color, surface, PRIMARY_CONTRAST).toRgb(); + case IconEmphasis::Quiet: + return reachContrast(blend(color, surface, QUIET_BLEND), surface, QUIET_CONTRAST_FLOOR).toRgb(); + case IconEmphasis::Secondary: + break; + } + return color; +} + +double primaryContrastFloor() +{ + return PRIMARY_CONTRAST; +} + +double quietBlend() +{ + return QUIET_BLEND; +} + +double quietContrastFloor() +{ + return QUIET_CONTRAST_FLOOR; +} } // namespace ThemeIconInk diff --git a/cockatrice/src/interface/theme_icon_ink.h b/cockatrice/src/interface/theme_icon_ink.h index 80c956cef..6ba90a239 100644 --- a/cockatrice/src/interface/theme_icon_ink.h +++ b/cockatrice/src/interface/theme_icon_ink.h @@ -6,6 +6,8 @@ #ifndef THEME_ICON_INK_H #define THEME_ICON_INK_H +#include "theme_icon.h" + #include /** @@ -93,6 +95,33 @@ QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, * already ask for that when there is no visible label. */ 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. + * + * @param color Hue-resolved glyph color. + * @param surface Surface the glyph is drawn on. + * @param emphasis How much attention the glyph claims. + * @return The emphasized color, as an RGB color. + */ +QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis); + +/** @brief Contrast floor the primary level reaches, so it out-reads the default. */ +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. */ +double quietContrastFloor(); } // namespace ThemeIconInk #endif // THEME_ICON_INK_H diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index fc5481a45..c9e501d6e 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -1348,3 +1348,206 @@ TEST(IconInkPreview, WritesPngsWhenRequested) ASSERT_TRUE(image.save(path)) << path.toStdString(); } } + +namespace +{ +/** @brief Applies a shipped palette file to the application for a test. */ +bool applyShippedPalette(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"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + if (!config.hasPalette()) { + return false; + } + 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. + * + * 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. + */ +TEST_F(ThemeIconRenderTest, PrimaryEmphasisUsesTheAccent) +{ + ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + + const QColor secondary = IconTint().resolve(QIcon::Normal); + const QColor primary = IconTint::primary().resolve(QIcon::Normal); + + 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())); +} + +/** + * @brief The primary level out-reads the default one on both schemes. + * + * The default ink borrows enough accent to sit off the palette's pole (5.4:1 on + * light, 6.4:1 on dark), so the 7:1 primary floor is measurably above it in both + * schemes instead of being lost against a near-black or near-white default. + * Hue alone would not keep the order; the contrast target does. + */ +TEST_F(ThemeIconRenderTest, PrimaryOutReadsSecondaryOnBothSchemes) +{ + for (const bool dark : {false, true}) { + ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), dark)); + const QColor surface = surfaceColor(); + const QColor secondary = IconTint().resolve(QIcon::Normal); + const QColor primary = IconTint::primary().resolve(QIcon::Normal); + EXPECT_NE(primary, secondary) << (dark ? "dark" : "light"); + EXPECT_GT(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::contrastRatio(secondary, surface)) + << (dark ? "dark" : "light"); + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor()) + << (dark ? "dark" : "light"); + } +} + +/** @brief A quiet glyph recedes toward its surface without dropping below 3:1. */ +TEST_F(ThemeIconRenderTest, QuietEmphasisRecedesButStaysLegible) +{ + ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + + 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()); +} + +/** @brief Emphasis changes how loud a semantic color is, never which color it is. */ +TEST_F(ThemeIconRenderTest, EmphasisKeepsASemanticHue) +{ + ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + + 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); + } +} + +/** @brief Every emphasis keeps the selected-row swap to HighlightedText. */ +TEST_F(ThemeIconRenderTest, SelectedStillFallsBackToHighlightedText) +{ + QPalette palette = qApp->palette(); + const QColor selectedText(QStringLiteral("#222222")); + palette.setColor(QPalette::Active, QPalette::Highlight, QColor(QStringLiteral("#000000"))); + 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. + EXPECT_EQ(IconTint().resolve(QIcon::Selected), selectedText); + EXPECT_EQ(IconTint::quiet().resolve(QIcon::Selected), selectedText); +} + +/** + * @brief Writes the emphasis ladder sheet when asked; skipped otherwise. + * + * One strip per scheme: a mock toolbar with body text, a quiet labelling glyph, + * secondary actions, the primary action, and the primary action on an + * accent-filled button. The point is the hierarchy at a glance, not the glyph. + */ +TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) +{ + 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 ROW_HEIGHT = 44; + constexpr int ICON_SIZE = 20; + constexpr qreal DPR = 2.0; + constexpr int WIDTH = 760; + + struct Sample + { + const char *glyph; + const char *label; + IconTint tint; + }; + + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); + ASSERT_TRUE(palette.text.isValid()); + ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), dark)); + + const QList samples = { + {nullptr, "body text for comparison", {}}, + {"search", "quiet hint", IconTint::quiet()}, + {"settings", "secondary", IconTint()}, + {"arrow_right", "primary", IconTint::primary()}, + {"increment", "primary, positive", IconTint(AppColor::Positive, IconEmphasis::Primary)}, + }; + + const int logicalHeight = MARGIN + 28 + static_cast(samples.size()) * ROW_HEIGHT + ROW_HEIGHT + MARGIN; + QImage image(QSize(qRound(WIDTH * DPR), qRound(logicalHeight * DPR)), QImage::Format_ARGB32_Premultiplied); + image.fill(palette.window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + qreal y = MARGIN; + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, y), (dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")) + + QStringLiteral(" — emphasis ladder")); + y += 28; + + for (const Sample &sample : samples) { + if (sample.glyph != nullptr) { + const QByteArray svg = readIconSvg(sample.glyph); + ASSERT_FALSE(svg.isEmpty()) << sample.glyph; + const QColor ink = sample.tint.resolve(QIcon::Normal); + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap glyph(QSize(ICON_SIZE, ICON_SIZE)); + glyph.fill(Qt::transparent); + QPainter glyphPainter(&glyph); + glyphPainter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&glyphPainter); + glyphPainter.end(); + painter.drawPixmap(QPointF(MARGIN, y - ICON_SIZE + 4), glyph); + } + + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 10)); + painter.drawText(QPointF(MARGIN + ICON_SIZE + 16, y), QString::fromLatin1(sample.label)); + y += ROW_HEIGHT; + } + + // The primary action on an accent-filled button: the container carries + // the emphasis, and the glyph has to switch to the on-accent ink. + const QRectF button(MARGIN, y - 28, 220, 34); + painter.setBrush(palette.accent); + painter.setPen(Qt::NoPen); + painter.drawRoundedRect(button, 6, 6); + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 10)); + painter.drawText(QPointF(MARGIN + 12, y - 6), QStringLiteral("primary button: accent fill + on-accent glyph")); + painter.end(); + + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("emphasis-preview-dark.png") + : QStringLiteral("emphasis-preview-light.png")); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +}