diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 564e19891..cc6debdf0 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -154,6 +154,7 @@ set(cockatrice_SOURCES src/interface/theme_icon.cpp src/interface/theme_icon_badge.cpp src/interface/theme_icon_fill.cpp + src/interface/theme_icon_ink.cpp src/interface/theme_icon_sticker.cpp src/interface/theme_manager.cpp src/interface/palette_editor/color_button.cpp diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index 622534d8c..3557a9441 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -3,6 +3,7 @@ #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" @@ -319,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; } @@ -346,16 +364,25 @@ 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 ThemeIconBadge::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::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; const QPalette::ColorRole effective = mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole; - return qApp->palette().color(group, effective); + 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); + } + return color; } QIcon loadThemedIcon(QStringView prefix, IconTint tint, IconStyle style) diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h index e1a867fc0..231221c93 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. @@ -68,6 +96,16 @@ public: * 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. + * + * 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. + * + * 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; @@ -81,6 +119,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_badge.cpp b/cockatrice/src/interface/theme_icon_badge.cpp index e9ec057f7..086d2be43 100644 --- a/cockatrice/src/interface/theme_icon_badge.cpp +++ b/cockatrice/src/interface/theme_icon_badge.cpp @@ -10,7 +10,6 @@ #include #include #include -#include namespace { @@ -47,22 +46,6 @@ DiscFill discFill(const QColor &tone) namespace ThemeIconBadge { -double relativeLuminance(const QColor &color) -{ - const auto linear = [](double channel) { - channel /= 255.0; - return channel <= 0.03928 ? channel / 12.92 : std::pow((channel + 0.055) / 1.055, 2.4); - }; - return 0.2126 * linear(color.red()) + 0.7152 * linear(color.green()) + 0.0722 * linear(color.blue()); -} - -double contrastRatio(const QColor &first, const QColor &second) -{ - const double a = relativeLuminance(first); - const double b = relativeLuminance(second); - return (qMax(a, b) + 0.05) / (qMin(a, b) + 0.05); -} - QColor glyphColor(const QColor &tone) { // The badge carries the ink the pre-sticker badge did: white on the light diff --git a/cockatrice/src/interface/theme_icon_badge.h b/cockatrice/src/interface/theme_icon_badge.h index cdb43cbd9..412082377 100644 --- a/cockatrice/src/interface/theme_icon_badge.h +++ b/cockatrice/src/interface/theme_icon_badge.h @@ -31,16 +31,6 @@ */ namespace ThemeIconBadge { -/** - * @brief WCAG relative luminance, used to test a color against a background. - * - * Used by IconTint::resolve()'s selected-row contrast test. - */ -double relativeLuminance(const QColor &color); - -/** @brief WCAG contrast ratio between two opaque colors, from 1.0 to 21.0. */ -double contrastRatio(const QColor &first, const QColor &second); - /** * @brief Glyph color for a badge disc. * diff --git a/cockatrice/src/interface/theme_icon_ink.cpp b/cockatrice/src/interface/theme_icon_ink.cpp new file mode 100644 index 000000000..1b0748091 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_ink.cpp @@ -0,0 +1,135 @@ +#include "theme_icon_ink.h" + +#include +#include + +namespace +{ +// How much of the theme accent a neutral glyph borrows. A quarter still reads as +// near-black at 16-24px, which is what made the first cut look unchanged, so the +// glyph takes most of the accent's hue. It stops short of the accent itself: the +// glyph keeps its own contrast against the surface instead of becoming a second +// 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; +// 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; +} // namespace + +namespace ThemeIconInk +{ +double relativeLuminance(const QColor &color) +{ + const auto linear = [](double channel) { + channel /= 255.0; + return channel <= 0.03928 ? channel / 12.92 : std::pow((channel + 0.055) / 1.055, 2.4); + }; + return 0.2126 * linear(color.red()) + 0.7152 * linear(color.green()) + 0.0722 * linear(color.blue()); +} + +double contrastRatio(const QColor &first, const QColor &second) +{ + const double a = relativeLuminance(first); + const double b = relativeLuminance(second); + return (qMax(a, b) + 0.05) / (qMin(a, b) + 0.05); +} + +QColor blend(const QColor &color, const QColor &target, double amount) +{ + const double clamped = qBound(0.0, amount, 1.0); + const auto channel = [clamped](int from, int to) { return qRound(from + (to - from) * clamped); }; + return QColor(channel(color.red(), target.red()), channel(color.green(), target.green()), + channel(color.blue(), target.blue())); +} + +QColor reachContrast(const QColor &color, const QColor &background, double minRatio) +{ + if (contrastRatio(color, background) >= minRatio) { + 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) { + return pole; + } + + // The ratio is monotone along the path from color to pole, so a bisection + // finds the first point that clears the floor. Stepping the opposite way + // from the pole keeps the glyph as close to its palette color as possible. + double low = 0.0; + double high = 1.0; + for (int step = 0; step < CONTRAST_SEARCH_STEPS; ++step) { + const double middle = (low + high) / 2.0; + if (contrastRatio(blend(color, pole, middle), background) >= minRatio) { + high = middle; + } else { + low = middle; + } + } + return blend(color, pole, high); +} + +QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix) +{ + return reachContrast(blend(text, accent, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb(); +} + +double defaultAccentMix() +{ + return ACCENT_MIX; +} + +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 new file mode 100644 index 000000000..6ba90a239 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_ink.h @@ -0,0 +1,127 @@ +/** + * @file theme_icon_ink.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_INK_H +#define THEME_ICON_INK_H + +#include "theme_icon.h" + +#include + +/** + * @brief Turns a text color into the ink a glyph is painted with. + * + * A glyph is not a word. Body text is read in sentences, at a size where a 2px + * stroke would be unusually heavy; a glyph is a 16px mark of the same stroke, + * and antialiasing eats its edges, so the same color that reads as solid type + * reads as a thin gray line beside it. Painting icons with `WindowText` verbatim + * is what makes them recede. + * + * The rule here is deliberately small: give the glyph a little of the theme's + * accent chroma, then hold it to a contrast floor against the surface it sits + * 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 rule only applies to the neutral label roles. A semantic tint + * (`AppColor::Positive`, `QPalette::Highlight`, `QPalette::Link`) is a hue the + * call site chose on purpose, so it is left alone. + */ +namespace ThemeIconInk +{ +/** + * @brief WCAG relative luminance of an opaque color. + */ +double relativeLuminance(const QColor &color); + +/** + * @brief WCAG contrast ratio between two opaque colors, from 1.0 to 21.0. + */ +double contrastRatio(const QColor &first, const QColor &second); + +/** + * @brief Mixes two colors, @p amount = 0 returning @p color and 1 returning @p target. + * + * RGB interpolation, with no gamma or hue-space correction: the inputs are + * palette colors that already sit in sRGB, and the result has to be predictable + * enough for a theme author to reason about it. + */ +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. + * + * @param color Color to adjust. + * @param background Surface the color is drawn on. + * @param minRatio Contrast floor, as a WCAG ratio. + * @return The adjusted color; the nearer pole when even that cannot reach it. + */ +QColor reachContrast(const QColor &color, const QColor &background, double minRatio); + +/** + * @brief Accent share used by production callers of glyphInk(). + * + * Exposed so the preview sheet and the tests name the value they pin instead of + * repeating the literal, and so the default argument below has exactly one + * source: the constant in theme_icon_ink.cpp. + */ +double defaultAccentMix(); + +/** + * @brief Icon ink for a neutral glyph, given the palette it is drawn from. + * + * @param text Neutral text color, normally `QPalette::WindowText`. + * @param surface Surface the glyph is drawn on, normally `QPalette::Window`. + * @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. + * @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()); + +/** + * @brief Contrast floor a glyph that labels a control has to clear. + * + * 4.5:1, the Text-level WCAG ratio: an icon that is the only label for a button + * is carrying text, and the icon guidelines in `.opencode/docs/ui-assets.md` + * 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/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 50799d496..b500f79ab 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -34,6 +34,7 @@ set(oracle_SOURCES ../cockatrice/src/interface/theme_icon.cpp ../cockatrice/src/interface/theme_icon_badge.cpp ../cockatrice/src/interface/theme_icon_fill.cpp + ../cockatrice/src/interface/theme_icon_ink.cpp ../cockatrice/src/interface/theme_icon_sticker.cpp ../cockatrice/src/interface/theme_manager.cpp ${VERSION_STRING_CPP} diff --git a/tests/theme_migration/CMakeLists.txt b/tests/theme_migration/CMakeLists.txt index d0bd357b3..4f9d6dce4 100644 --- a/tests/theme_migration/CMakeLists.txt +++ b/tests/theme_migration/CMakeLists.txt @@ -8,6 +8,7 @@ cockatrice_add_test( ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_badge.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_fill.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_ink.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_sticker.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index ee078555d..f51282e0a 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -1,6 +1,7 @@ #include "interface/theme_icon.h" #include "interface/theme_icon_badge.h" #include "interface/theme_icon_fill.h" +#include "interface/theme_icon_ink.h" #include "interface/theme_icon_sticker.h" #include "interface/theme_manager.h" #include "libcockatrice/utility/svg_recolor.h" @@ -75,6 +76,20 @@ int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) return count; } +/** + * @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. + */ +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)); +} + /** @brief Opaque pixels that are not the given rim color, i.e. the gradient fill. */ int countOpaquePixelsOtherThan(const QPixmap &pixmap, const QColor &excluded) { @@ -152,11 +167,12 @@ TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr) const QIcon icon = loadThemedIcon(QStringLiteral("icons/search")); ASSERT_FALSE(icon.isNull()); + const QColor firstInk = expectedNeutralInk(qApp->palette()); const QPixmap normal = icon.pixmap(QSize(24, 24), 1.0); ASSERT_FALSE(normal.isNull()); EXPECT_EQ(normal.size(), QSize(24, 24)); EXPECT_DOUBLE_EQ(normal.devicePixelRatio(), 1.0); - EXPECT_GT(countOpaquePixels(normal, QColor(QStringLiteral("#123456"))), 0); + EXPECT_GT(countOpaquePixels(normal, firstInk), 0); const QPixmap hidpi = icon.pixmap(QSize(24, 24), 2.0); ASSERT_FALSE(hidpi.isNull()); @@ -165,8 +181,10 @@ TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr) palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#abcdef"))); qApp->setPalette(palette); + const QColor secondInk = expectedNeutralInk(qApp->palette()); + EXPECT_NE(firstInk, secondInk) << "the icon did not follow the palette"; const QPixmap flipped = icon.pixmap(QSize(24, 24), 1.0); - EXPECT_GT(countOpaquePixels(flipped, QColor(QStringLiteral("#abcdef"))), 0); + EXPECT_GT(countOpaquePixels(flipped, secondInk), 0); QPalette selectedPalette = qApp->palette(); selectedPalette.setColor(QPalette::HighlightedText, QColor(QStringLiteral("#00ff00"))); @@ -179,6 +197,7 @@ TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr) TEST_F(ThemeIconRenderTest, SentinelUsesPaletteColor) { QPalette palette; + palette.setColor(QPalette::Window, QColor(Qt::black)); palette.setColor(QPalette::WindowText, QColor(Qt::white)); qApp->setPalette(palette); @@ -186,7 +205,7 @@ TEST_F(ThemeIconRenderTest, SentinelUsesPaletteColor) ASSERT_FALSE(pixmap.isNull()); EXPECT_EQ(pixmap.size(), QSize(24, 24)); - EXPECT_GT(countOpaquePixels(pixmap, Qt::white), 0); + EXPECT_GT(countOpaquePixels(pixmap, expectedNeutralInk(qApp->palette())), 0); } TEST_F(ThemeIconRenderTest, NonSentinelSvgReturnsNull) @@ -221,7 +240,7 @@ TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved) const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("lower.svg")); ASSERT_FALSE(pixmap.isNull()); - EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#123456"))), 0); + EXPECT_GT(countOpaquePixels(pixmap, expectedNeutralInk(qApp->palette())), 0); } /** @@ -279,7 +298,12 @@ TEST_F(ThemeIconRenderTest, SelectedPaletteTintUsesHighlightedText) qApp->setPalette(palette); const IconTint tint(QPalette::WindowText); - EXPECT_EQ(tint.resolve(QIcon::Normal), activeText); + // 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())); + // 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); } @@ -1097,3 +1121,519 @@ TEST(IconBadgePreview, WritesPngsWhenRequested) ASSERT_TRUE(image.save(path)) << path.toStdString(); } } + +namespace +{ +/** @brief Palette roles the ink rule reads, from one shipped theme file. */ +struct InkPalette +{ + QColor text; + QColor window; + QColor button; + QColor accent; +}; + +/** + * @brief Loads a shipped palette file, or returns a default-constructed palette. + * + * The theme files are the only place the shipped values live, so the rule is + * tested against them rather than against a copy of them. + */ +InkPalette shippedPalette(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 {}; + } + 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)}; +} +} // namespace + +TEST(IconInk, GivesNeutralGlyphsAccentChroma) +{ + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), false); + ASSERT_TRUE(palette.text.isValid()); + + const QColor ink = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); + EXPECT_NE(ink, palette.text) << "a glyph must not be painted with body-text ink verbatim"; + // The borrowed accent is a lighter color than the near-black text ink, so + // the glyph gets a little lighter as well as more chromatic. What must not + // change is its legibility, its blue cast, and the fact that it reads as a + // richer color than plain text. + EXPECT_GT(ink.hsvSaturation(), palette.text.hsvSaturation()) << "the glyph gained no chroma"; + EXPECT_GT(ink.blue(), ink.red()) << "accent chroma did not reach the glyph"; + EXPECT_GE(ThemeIconInk::contrastRatio(ink, palette.window), ThemeIconInk::labelContrastFloor()); +} + +TEST(IconInk, MeetsTheLabelFloorOnEveryShippedPalette) +{ + 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; + } + ++checked; + const QColor ink = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); + EXPECT_GE(ThemeIconInk::contrastRatio(ink, palette.window), ThemeIconInk::labelContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; +} + +TEST(IconInk, KeepsADeliberateHueWhenTheTextColorIsWeak) +{ + // A theme whose text color is deliberately low-contrast still gets a glyph + // that clears the floor, and the hue survives the adjustment. + const QColor surface(QStringLiteral("#f4f4f4")); + const QColor weakText(QStringLiteral("#c9c9c9")); + const QColor ink = ThemeIconInk::glyphInk(weakText, surface, QColor(QStringLiteral("#c9c9c9"))); + EXPECT_GE(ThemeIconInk::contrastRatio(ink, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_LT(ink.value(), weakText.value()); +} + +/** + * @brief Writes the glyph-ink comparison sheet when asked; skipped otherwise. + * + * One row per accent share, so the trade-off between "still neutral chrome" and + * "clearly an icon" can be judged against the body text drawn beside the glyph. + * Lives in the test binary so the colors come from the same rule the engine uses. + */ +TEST(IconInkPreview, WritesPngsWhenRequested) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + + 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 const char *GLYPHS[] = {"search", "info", "settings", "filter", "increment"}; + constexpr int SIZES[] = {15, 16, 24, 48}; + constexpr int LAST_SIZE = SIZES[std::size(SIZES) - 1]; + constexpr int CELL_GAP = 10; + + const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + constexpr int MARGIN = 20; + constexpr int SIZE_LABEL_WIDTH = 46; + constexpr int BODY_WIDTH = 46; + constexpr int COLUMN_GAP = 28; + constexpr int ROW_HEIGHT = 58; + constexpr qreal DPR = 2.0; + + // The same five glyphs at the sizes the client actually draws them, so the + // comparison is one the reviewer can read rather than a color swatch. + const int glyphsWidth = + static_cast(std::size(GLYPHS)) * LAST_SIZE + (static_cast(std::size(GLYPHS)) - 1) * CELL_GAP; + const int oldX = MARGIN + SIZE_LABEL_WIDTH + BODY_WIDTH; + const int newX = oldX + glyphsWidth + COLUMN_GAP; + const int logicalWidth = newX + glyphsWidth + MARGIN; + const int blockHeight = 46 + static_cast(std::size(SIZES)) * ROW_HEIGHT + 10; + const int logicalHeight = MARGIN + 24 + 20 + 26 + 2 * blockHeight + MARGIN; + + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); + ASSERT_TRUE(palette.text.isValid()); + + 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); + + const QFont bodyFont(QStringLiteral("Sans"), 10); + const QFont labelFont(QStringLiteral("Sans"), 8); + const QFont captionFont(QStringLiteral("Sans"), 9, QFont::DemiBold); + + qreal y = MARGIN; + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 11, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, y), (dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")) + + QStringLiteral(" — neutral glyph ink")); + y += 18; + painter.setFont(labelFont); + painter.drawText(QPointF(MARGIN, y), + QStringLiteral("Body text is the same in both columns; only the glyph ink changes.")); + y += 20; + + // The two colors as filled swatches: at 15-24px the glyph difference is + // a hue shift, and the hex pair makes it measurable at a glance. + const QColor windowOld = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent, 0.0); + const QColor windowNew = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); + qreal swatchX = MARGIN; + for (const auto &[ink, label] : {std::pair(windowOld, QStringLiteral("before")), + std::pair(windowNew, QStringLiteral("after"))}) { + painter.setPen(Qt::NoPen); + painter.setBrush(ink); + painter.drawRoundedRect(QRectF(swatchX, y - 10, 14, 14), 3, 3); + painter.setBrush(Qt::NoBrush); + painter.setPen(palette.text); + painter.drawText(QPointF(swatchX + 20, y), QStringLiteral("%1 %2").arg(label, ink.name(QColor::HexRgb))); + swatchX += 150; + } + y += 24; + + for (const bool onButton : {false, true}) { + const QColor surface = onButton ? palette.button : palette.window; + const QColor oldInk = ThemeIconInk::glyphInk(palette.text, surface, palette.accent, 0.0); + const QColor newInk = ThemeIconInk::glyphInk(palette.text, surface, palette.accent); + + const qreal blockTop = y; + if (onButton) { + painter.fillRect(QRectF(0, blockTop, logicalWidth, blockHeight), surface); + } + + painter.setPen(palette.text); + painter.setFont(captionFont); + painter.drawText(QPointF(MARGIN, blockTop + 14), + onButton ? QStringLiteral("Button surface") : QStringLiteral("Window surface")); + painter.setFont(labelFont); + painter.drawText(QPointF(MARGIN, blockTop + 34), QStringLiteral("size")); + painter.drawText(QPointF(MARGIN + SIZE_LABEL_WIDTH, blockTop + 34), QStringLiteral("body")); + painter.drawText(QPointF(oldX, blockTop + 34), QStringLiteral("before: WindowText")); + painter.drawText( + QPointF(newX, blockTop + 34), + QStringLiteral("after: %1% accent ink").arg(qRound(ThemeIconInk::defaultAccentMix() * 100))); + + qreal rowY = blockTop + 46; + for (const int side : SIZES) { + const qreal baseline = rowY + ROW_HEIGHT / 2.0 + 3.5; + painter.setFont(labelFont); + painter.drawText(QPointF(MARGIN, baseline), QStringLiteral("%1 px").arg(side)); + painter.setFont(bodyFont); + painter.drawText(QPointF(MARGIN + SIZE_LABEL_WIDTH, baseline), QStringLiteral("Aa")); + + for (std::size_t i = 0; i < std::size(GLYPHS); ++i) { + const QByteArray svg = readIconSvg(GLYPHS[i]); + 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))); + } + rowY += ROW_HEIGHT; + } + y = blockTop + blockHeight; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("ink-preview-dark.png") + : QStringLiteral("ink-preview-light.png")); + 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 sheets for every shipped palette theme. + * + * One sheet per theme and scheme: the neutral, positive and negative families at + * their quiet, secondary and primary levels, each as a line glyph, a sticker and + * a badge, three glyphs per family, at 16 and 24 px, on the window and button + * surfaces. The tone is the one the emphasis rule resolves for that surface, so + * the sheet is what the engine paints rather than a hand-picked sample. + */ +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 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"}; + constexpr IconEmphasis LEVELS[] = {IconEmphasis::Quiet, IconEmphasis::Secondary, IconEmphasis::Primary}; + + struct Family + { + const char *name; + const char *glyphs[3]; + AppColor::Role role; + bool neutral; + }; + const QList families = { + {"Neutral", {"settings", "info", "player"}, AppColor::Positive, true}, + {"Positive", {"increment", "ready_start", "arrow_up"}, AppColor::Positive, false}, + {"Negative", {"decrement", "not_ready_start", "remove_row"}, AppColor::Negative, false}, + }; + const QStringList themes = { + QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), + QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), + }; + + 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()) * 3 * ROW_HEIGHT + PANEL_PADDING; + const int logicalWidth = 2 * MARGIN + panelWidth; + const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; + + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + ASSERT_TRUE(palette.text.isValid()) << theme.toStdString(); + + // The emphasis rule reads the live palette, so apply the theme + // before resolving tones for the sheet. + ASSERT_TRUE(applyShippedPalette(theme, dark)); + + const auto toneFor = [&](const Family &family, int level) { + QColor base; + if (family.neutral) { + base = level == 2 ? palette.accent + : ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); + } else { + base = ThemeIconFill::saturated(defaultAppColor(family.role, dark)); + } + return ThemeIconInk::forEmphasis(base, palette.window, LEVELS[level]); + }; + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(palette.window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, emphasis across treatments") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + struct Surface + { + const char *name; + QColor background; + QColor text; + }; + const QList surfaces = { + {"Window surface", palette.window, palette.text}, + {"Button surface", palette.button, palette.text}, + }; + + 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 < 3; ++level) { + const QColor ink = toneFor(family, level); + ASSERT_TRUE(ink.isValid()) + << theme.toStdString() << " " << family.name << " " << LEVEL_NAMES[level]; + + painter.setPen(surface.text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, y + 14), + QStringLiteral("%1 / %2").arg(QString::fromLatin1(family.name), + QString::fromLatin1(LEVEL_NAMES[level]))); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + + for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) { + const qreal cellX = contentLeft + LABEL_WIDTH + static_cast(treatment) * CELL_WIDTH; + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const int side = SIZES[sizeIndex]; + const QSize size(side, side); + const qreal rowTop = y + (sizeIndex == 0 ? 4 : 28); + for (std::size_t glyphIndex = 0; glyphIndex < std::size(family.glyphs); ++glyphIndex) { + const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); + ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; + QPixmap pixmap; + if (treatment == 0) { + pixmap = renderLine(svg, ink, size); + } else if (treatment == 1) { + pixmap = ThemeIconSticker::renderSticker(svg, ink, size); + } else { + pixmap = ThemeIconBadge::renderBadge(svg, ink, size); + } + const qreal x = cellX + 6 + static_cast(glyphIndex) * GLYPH_SLOT + + (GLYPH_SLOT - side) / 2.0; + painter.drawPixmap(QPointF(x, rowTop + (GLYPH_SLOT - side) / 2.0), pixmap); + } + } + } + y += ROW_HEIGHT; + } + } + panelTop += panelHeight + 24; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("emphasis-preview-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } + } +}