[Icons] Paint neutral glyphs with icon ink instead of body-text ink

Neutral icons resolved `currentColor` straight to `QPalette::WindowText`. A
glyph is not a word: it is a 16-24px mark drawn with a 2-unit stroke, so
antialiasing thins it until it reads lighter than the text beside it, in the
one color the palette reserves for reading. The result is that icons recede
into the chrome exactly where they should anchor it.

Add `theme_icon_ink`, one rule with one job: mix a share of the theme accent
into the neutral text color, then hold the result to a contrast floor. The
accent share gives the glyph chroma the text does not have, so it reads as an
icon rather than as leftover text, and it is derived from `QPalette::Highlight`
rather than a new token. The floor keeps a theme with a weak text color from
producing an illegible glyph.

Only the neutral label roles (`WindowText`, `Text`) are inked. Semantic tints
are hues the call site chose on purpose, disabled uses the disabled group, and
the selected-row swap to `HighlightedText` is unchanged.

The WCAG contrast math moves from `theme_icon_badge` to the new unit, so badge
ink decisions and the floor share one implementation.

Tests: the ink rule against every shipped palette, the weak-text floor, and the
palette-following render tests now ask the rule instead of restating its
output. A preview sheet sweeps the accent share at 0-35% over window and button
surfaces for both schemes.
This commit is contained in:
Lukas Brübach 2026-11-06 23:02:58 +01:00
parent 4a0c956062
commit 86c8790b48
10 changed files with 467 additions and 36 deletions

View file

@ -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

View file

@ -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)

View file

@ -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;

View file

@ -10,7 +10,6 @@
#include <QRectF>
#include <QSvgRenderer>
#include <QtGlobal>
#include <cmath>
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

View file

@ -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.
*

View file

@ -0,0 +1,90 @@
#include "theme_icon_ink.h"
#include <QtGlobal>
#include <cmath>
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

View file

@ -0,0 +1,98 @@
/**
* @file theme_icon_ink.h
* @ingroup Interface
*/
#ifndef THEME_ICON_INK_H
#define THEME_ICON_INK_H
#include <QColor>
/**
* @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

View file

@ -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}

View file

@ -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

View file

@ -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<int>(std::size(GLYPHS)) * LAST_SIZE + (static_cast<int>(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<int>(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<QColor, QString>(windowOld, QStringLiteral("before")),
std::pair<QColor, QString>(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<qreal>(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();
}
}