diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index a073e8a07..449804f2c 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..40b51bb0a 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" @@ -347,15 +348,24 @@ QColor IconTint::resolve(QIcon::Mode mode) const // 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, highlight) >= MIN_SELECTED_CONTRAST ? color : qApp->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; - 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)) { + return ThemeIconInk::glyphInk(color, palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Highlight)); + } + 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..95cfba615 100644 --- a/cockatrice/src/interface/theme_icon.h +++ b/cockatrice/src/interface/theme_icon.h @@ -68,6 +68,12 @@ 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. */ QColor resolve(QIcon::Mode mode) const; diff --git a/cockatrice/src/interface/theme_icon_badge.cpp b/cockatrice/src/interface/theme_icon_badge.cpp index 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..bef5862b4 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_ink.cpp @@ -0,0 +1,90 @@ +#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; +// 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 black on a light surface, toward + // white on a dark one. The hue is kept by interpolating, not by replacing. + const QColor pole = relativeLuminance(background) > 0.5 ? QColor(Qt::black) : QColor(Qt::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; +} +} // 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..80c956cef --- /dev/null +++ b/cockatrice/src/interface/theme_icon_ink.h @@ -0,0 +1,98 @@ +/** + * @file theme_icon_ink.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_INK_H +#define THEME_ICON_INK_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(); +} // 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..fc5481a45 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,8 +76,22 @@ int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) return count; } -/** @brief Opaque pixels that are not the given rim color, i.e. the gradient fill. */ -int countOpaquePixelsOtherThan(const QPixmap &pixmap, const QColor &excluded) +/** + * @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) { const QImage image = pixmap.toImage(); int count = 0; @@ -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,230 @@ 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(); + } +}