diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index 09e0fb140..4b643cc84 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -30,6 +30,49 @@ 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. + */ +ThemeIconFill::Scheme activeScheme() +{ + const QPalette &palette = qApp->palette(); + return palette.color(QPalette::Active, QPalette::Window).lightness() < 128 ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light; +} + /** * @brief Reads an SVG through the `theme:` search path. */ @@ -264,7 +307,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) { @@ -282,7 +325,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,34 +361,6 @@ private: mutable bool hasIntrinsicSize = false; }; -/** - * @brief The role an emphasis level selects inside a base role's family. - * - * The theme authors all three levels, so emphasis is a lookup instead of a - * color computation. Naming a variant (`IconStrong`, `PositiveSoft`) is an - * explicit request and keeps that role at every emphasis; only the base roles - * take part in the mapping. Accent roles have no neutral middle member, so they - * are always used as named. - */ -AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis) -{ - switch (role) { - case AppColor::Icon: - return emphasis == IconEmphasis::Primary ? AppColor::IconStrong - : emphasis == IconEmphasis::Quiet ? AppColor::IconSoft - : AppColor::Icon; - case AppColor::Positive: - return emphasis == IconEmphasis::Primary ? AppColor::PositiveStrong - : emphasis == IconEmphasis::Quiet ? AppColor::PositiveSoft - : AppColor::Positive; - case AppColor::Negative: - return emphasis == IconEmphasis::Primary ? AppColor::NegativeStrong - : emphasis == IconEmphasis::Quiet ? AppColor::NegativeSoft - : AppColor::Negative; - default: - return role; - } -} } // namespace IconTint::IconTint(QPalette::ColorRole role, IconEmphasis _emphasis) : paletteRole(role), emphasis(_emphasis) @@ -378,9 +393,9 @@ QColor IconTint::resolve(QIcon::Mode mode) const return palette.color(QPalette::Disabled, QPalette::WindowText); } // The theme authors one color per emphasis level, so the paint path - // uses the resolved role verbatim. The filled treatments apply their - // own saturation floor when a disc or a fill needs mass; a line glyph - // keeps the color the author picked. + // 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; @@ -428,7 +443,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 f09267a91..1bca9fd7f 100644 --- a/cockatrice/src/interface/theme_icon.h +++ b/cockatrice/src/interface/theme_icon.h @@ -99,8 +99,8 @@ public: * * Application colors are returned verbatim: the theme author stores one * value per emphasis level, so the paint path does not rewrite it. The - * filled treatments apply their own saturation floor to a fill they have to - * carry; a line glyph keeps the author's color. + * 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 derives it from the accent and 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_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/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index e5156f354..672826462 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -336,9 +336,8 @@ TEST_F(ThemeIconRenderTest, SelectedAppColorKeepsAContrastingTint) * @brief A line glyph renders the authored role verbatim. * * The emphasis colors are authored data now, so the paint path must not - * rewrite them. A muted quiet role stays muted on a line glyph; a badge disc or - * a sticker fill boosts its own fill, because carrying a whole shape is a - * treatment concern rather than a role concern. + * 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, LineTintRendersTheAuthoredRoleVerbatim) { @@ -352,7 +351,6 @@ TEST_F(ThemeIconRenderTest, LineTintRendersTheAuthoredRoleVerbatim) const QPixmap pixmap = icon.pixmap(QSize(24, 24)); ASSERT_FALSE(pixmap.isNull()); EXPECT_GT(countOpaquePixels(pixmap, raw), 0); - EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(raw)), 0); } /** @brief A badge is a disc with a gradient fill, a dark ring and a contrast glyph. */ @@ -371,8 +369,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. @@ -384,40 +382,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); } @@ -426,8 +429,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) @@ -444,7 +447,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); @@ -487,7 +491,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. */ @@ -498,8 +502,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); } /** @@ -576,7 +580,7 @@ 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. */ @@ -594,7 +598,7 @@ 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, tone.darker(300)), 0); EXPECT_EQ(countOpaquePixels(pixmap, disabledText.darker(300)), 0); EXPECT_EQ(pixmap.toImage().pixelColor(0, 0).alpha(), 0); EXPECT_GT(countOpaquePixels(pixmap, disabledText), 0); @@ -631,13 +635,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()); @@ -651,10 +655,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. */ @@ -749,7 +757,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); } @@ -938,8 +946,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; @@ -972,8 +980,10 @@ 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), + ThemeIconBadge::renderBadge(svg, tone, QSize(CONTEXT_SIZE, CONTEXT_SIZE), + dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light)); painter.drawText(QPointF(x, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(badgeContext[i].name)); } @@ -984,7 +994,7 @@ 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); @@ -992,7 +1002,8 @@ TEST(IconStickerPreview, WritesPngsWhenRequested) if (j == 0) { pixmap = renderLine(svg, tone, side); } else if (j == 1) { - pixmap = ThemeIconBadge::renderBadge(svg, tone, side); + pixmap = ThemeIconBadge::renderBadge( + svg, tone, side, dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light); } else { pixmap = ThemeIconSticker::renderSticker(svg, tone, side); } @@ -1090,8 +1101,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; @@ -1101,7 +1112,9 @@ 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)) + const QPixmap pixmap = badge ? ThemeIconBadge::renderBadge(svg, tone, QSize(side, side), + dark ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light) : renderLine(svg, tone, QSize(side, side)); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; // Align all sizes on a common baseline so the weight @@ -1127,6 +1140,7 @@ namespace struct InkPalette { QColor text; + QColor buttonText; QColor window; QColor button; QColor accent; @@ -1152,9 +1166,10 @@ InkPalette shippedPalette(const QString &theme, bool dark) 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), - config.appColors}; + 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}; } /** @@ -1699,3 +1714,189 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) ASSERT_TRUE(image.save(path)) << path.toStdString(); } } + +/** + * @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. + * + * 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()) * 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(); + + 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 < 3; ++level) { + const AppColor::Role role = roleAt(family, level); + const QColor ink = palette.roles.value(role); + ASSERT_TRUE(ink.isValid()) + << theme.toStdString() << " " << family.name << " " << LEVEL_NAMES[level]; + const QColor bottom = 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), + QString::fromLatin1(LEVEL_NAMES[level]))); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + if (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]; + 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, 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(); + } + } +}