From 9ae7c6678cf8434f413cb35884ea5b70a2b457a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= <44814898+BruebachL@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:13:40 +0100 Subject: [PATCH] [Icons] Add an emphasis axis for icon ink Tint says which hue, style says how much shape, and nothing said how loud. Call sites that have a hierarchy (a card hover panel's nav strip, the visual database filter toolbar) could only pick between "the theme color" and "another theme color", so "this is the important one" had no way to be expressed and every glyph in a surface arrived with the same weight. Add `IconEmphasis` as the third axis: Quiet, Secondary, Primary. It travels inside `IconTint` instead of becoming a fourth `loadThemedIcon` parameter, so every existing call site keeps working and its look, and emphasis reads as part of the same "how should this glyph be inked" decision as the tint. - Primary lifts a neutral glyph to `QPalette::Highlight`, so the surface's own action color names the primary action instead of a second gray token, and pushes both neutral and tinted glyphs to a 7:1 floor. - Quiet blends toward the surface and stops at the 3:1 WCAG non-text floor, for glyphs that label a control rather than act (the magnifier in a search field, a section marker). - Secondary is the previous color verbatim. 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. The tests pin that order where it exists, and the hue shift still separates the levels at a glance. Disabled glyphs and the Selected -> HighlightedText swap are unchanged at every emphasis level. --- cockatrice/src/interface/theme_icon.cpp | 39 +++- cockatrice/src/interface/theme_icon.h | 44 +++- cockatrice/src/interface/theme_icon_ink.cpp | 40 ++++ cockatrice/src/interface/theme_icon_ink.h | 29 +++ .../theme_icon_render_test.cpp | 203 ++++++++++++++++++ 5 files changed, 341 insertions(+), 14 deletions(-) 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(); + } +}