[Icons] Paint neutral glyphs with icon ink and add an emphasis axis (#7788)

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

* [Icons] Add an emphasis axis for icon ink

Tint says which hue, style says how much shape, and nothing said how loud.
Call sites that have a hierarchy (a card hover panel's nav strip, the visual
database filter toolbar) could only pick between "the theme color" and
"another theme color", so "this is the important one" had no way to be
expressed and every glyph in a surface arrived with the same weight.

Add `IconEmphasis` as the third axis: Quiet, Secondary, Primary. It travels
inside `IconTint` instead of becoming a fourth `loadThemedIcon` parameter, so
every existing call site keeps working and its look, and emphasis reads as
part of the same "how should this glyph be inked" decision as the tint.

- Primary lifts a neutral glyph to `QPalette::Highlight`, so the surface's own
  action color names the primary action instead of a second gray token, and
  pushes both neutral and tinted glyphs to a 7:1 floor.
- Quiet blends toward the surface and stops at the 3:1 WCAG non-text floor,
  for glyphs that label a control rather than act (the magnifier in a search
  field, a section marker).
- Secondary is the previous color verbatim.

The default ink borrows enough accent to sit off the palette's pole (5.4:1 on
light, 6.4:1 on dark), so the 7:1 primary floor is measurably above it in both
schemes instead of being lost against a near-black or near-white default. The
tests pin that order where it exists, and the hue shift still separates the
levels at a glance.

Disabled glyphs and the Selected -> HighlightedText swap are unchanged at
every emphasis level.

* [Icons] Render the emphasis sheet for every shipped theme

The preview only rendered one theme, so a reviewer could not see that the
emphasis levels behave the same way across Fabric, Fusion, Leather,
Plasma and VelvetMarble. It now writes one sheet per theme and scheme:
the neutral, positive and negative families at all three levels, three
glyphs per family, as line, sticker and badge at 16 and 24 px, on the
window and button surfaces.

* Address review findings
This commit is contained in:
BruebachL 2026-11-08 19:09:01 +01:00 • committed by GitHub
parent e81ee02c24
commit 3e2884e6e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 888 additions and 44 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"
@ -319,26 +320,43 @@ private:
};
} // namespace
IconTint::IconTint(QPalette::ColorRole role) : paletteRole(role)
IconTint::IconTint(QPalette::ColorRole role, IconEmphasis _emphasis) : paletteRole(role), emphasis(_emphasis)
{
}
IconTint::IconTint(AppColor::Role role) : source(Source::AppColor), appRole(role)
IconTint::IconTint(AppColor::Role role, IconEmphasis _emphasis)
: source(Source::AppColor), appRole(role), emphasis(_emphasis)
{
}
IconTint IconTint::primary()
{
return IconTint(QPalette::WindowText, IconEmphasis::Primary);
}
IconTint IconTint::quiet()
{
return IconTint(QPalette::WindowText, IconEmphasis::Quiet);
}
QColor IconTint::resolve(QIcon::Mode mode) const
{
const QPalette &palette = qApp->palette();
const QColor surface = palette.color(QPalette::Active, QPalette::Window);
const QColor accent = palette.color(QPalette::Active, QPalette::Highlight);
if (source == Source::AppColor) {
// A disabled control must not keep signalling its semantic color.
if (mode == QIcon::Disabled) {
return qApp->palette().color(QPalette::Disabled, QPalette::WindowText);
return palette.color(QPalette::Disabled, QPalette::WindowText);
}
// The three treatments paint the same tone: a line glyph, a badge disc
// and a sticker fill must not disagree on how saturated the role is.
// The fill treatments apply this floor themselves, so this only changes
// the line path, and only for the desaturated dark-scheme tones.
const QColor color = ThemeIconFill::saturated(themeManager->appColor(appRole));
// the line path, and only for the desaturated dark-scheme tones. The
// emphasis then decides how loud that tone is, without changing its hue.
const QColor color =
ThemeIconInk::forEmphasis(ThemeIconFill::saturated(themeManager->appColor(appRole)), surface, emphasis);
if (mode != QIcon::Selected) {
return color;
}
@ -346,16 +364,25 @@ QColor IconTint::resolve(QIcon::Mode mode) const
// semantic tint when it stays legible there; otherwise the glyph would
// vanish into the selection.
constexpr double MIN_SELECTED_CONTRAST = 3.0;
const QColor highlight = qApp->palette().color(QPalette::Active, QPalette::Highlight);
return ThemeIconBadge::contrastRatio(color, highlight) >= MIN_SELECTED_CONTRAST
return ThemeIconInk::contrastRatio(color, accent) >= MIN_SELECTED_CONTRAST
? color
: qApp->palette().color(QPalette::Active, QPalette::HighlightedText);
: palette.color(QPalette::Active, QPalette::HighlightedText);
}
const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active;
const QPalette::ColorRole effective =
mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole;
return qApp->palette().color(group, effective);
const QColor color = palette.color(group, effective);
// A neutral label role is inked as an icon, not as body text: it borrows a
// little accent chroma and is held to a contrast floor. Semantic roles and
// the selected-row swap keep the color the palette gave them.
if (group == QPalette::Active && (effective == QPalette::WindowText || effective == QPalette::Text)) {
// The primary action of a surface takes the accent itself; the other
// levels stay neutral. Emphasis then sets the contrast the glyph pays.
const QColor base = emphasis == IconEmphasis::Primary ? accent : ThemeIconInk::glyphInk(color, surface, accent);
return ThemeIconInk::forEmphasis(base, surface, emphasis);
}
return color;
}
QIcon loadThemedIcon(QStringView prefix, IconTint tint, IconStyle style)

View file

@ -46,19 +46,47 @@ enum class IconStyle
Sticker, ///< filled glyph + dark outline, no disc
};
/**
* @brief Selects how much attention a glyph claims inside its surface.
*
* Tint says which hue, style says how much shape, emphasis says how loud. The
* three are independent: a primary action can be a line glyph in the theme
* accent, and a quiet one can be a sticker in a dimmed tone.
*
* Secondary is the default and the color every icon had before the axis
* existed, so existing call sites keep their look. Primary is for the one
* action a surface exists for: a neutral primary glyph takes the accent, and a
* tinted one is pushed to full contrast. Quiet is for glyphs that label rather
* than act, such as the magnifier inside a search field: legible, but never
* competing with the buttons next to it.
*/
enum class IconEmphasis
{
Quiet, // labels rather than acts
Secondary, // the default action weight
Primary, // the action the surface exists for
};
/**
* @brief Selects the color that replaces the `currentColor` sentinel.
*
* A palette role follows Qt's structural palette. An application color carries
* meaning that QPalette cannot express, such as positive and negative states.
* Callers choose the role. The active theme supplies the value.
* Callers pick the role by intent; the theme owns the concrete value. Emphasis
* is part of the same decision: both answer how the glyph is inked.
*/
class IconTint
{
public:
IconTint() = default;
IconTint(QPalette::ColorRole role); // implicit: existing palette-role callers
IconTint(AppColor::Role role); // implicit: semantic callers
IconTint(QPalette::ColorRole role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for role callers
IconTint(AppColor::Role role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for semantic callers
/** @brief A neutral glyph in the accent tone, for the surface's primary action. */
static IconTint primary();
/** @brief A dimmed neutral glyph, for a control that labels rather than acts. */
static IconTint quiet();
/**
* @brief Resolves the concrete color for an icon mode.
@ -68,6 +96,16 @@ public:
* color keeps the WindowText -> HighlightedText swap. Application colors go
* through the saturation floor shared by the filled treatments, so a line
* glyph, a badge disc and a sticker fill carry the same tone.
*
* Neutral label roles (WindowText, Text) resolve to icon ink rather than the
* raw palette color: ThemeIconInk::glyphInk borrows a little accent chroma
* and holds the glyph to a contrast floor, because a 2px glyph in body-text
* ink reads thinner than the text beside it. Semantic roles keep the hue the
* call site asked for.
*
* Emphasis is applied after the hue: Primary lifts a neutral glyph to the
* accent and pushes a tinted one to full contrast; Quiet blends either
* toward the surface down to the non-text legibility floor.
*/
QColor resolve(QIcon::Mode mode) const;
@ -81,6 +119,7 @@ private:
Source source = Source::Palette;
QPalette::ColorRole paletteRole = QPalette::WindowText;
AppColor::Role appRole = AppColor::AccentStrong;
IconEmphasis emphasis = IconEmphasis::Secondary;
};
/**

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,135 @@
#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;
// A primary glyph has to out-read the default one on both schemes. The accent is
// a mid-tone: on a light surface it is *lighter* than the neutral ink, and on a
// dark surface it is *darker*, so neither "the accent as-is" nor "a contrast
// floor shared with the default" keeps the primary above the secondary. A 7:1
// target does: it darkens the accent on light schemes and lightens it on dark
// ones, so the primary level always out-contrasts the default level.
constexpr double PRIMARY_CONTRAST = 7.0;
// A quiet glyph is blended this far into its surface. It has to stay findable
// without competing with the actions beside it.
constexpr double QUIET_BLEND = 0.55;
// WCAG 2.2 SC 1.4.11, the floor for a non-text element that carries meaning.
constexpr double QUIET_CONTRAST_FLOOR = 3.0;
// Binary-search depth for reachContrast(). 24 halvings resolve to well under
// one 8-bit channel step, so the result is stable across platforms.
constexpr int CONTRAST_SEARCH_STEPS = 24;
} // namespace
namespace ThemeIconInk
{
double relativeLuminance(const QColor &color)
{
const auto linear = [](double channel) {
channel /= 255.0;
return channel <= 0.03928 ? channel / 12.92 : std::pow((channel + 0.055) / 1.055, 2.4);
};
return 0.2126 * linear(color.red()) + 0.7152 * linear(color.green()) + 0.0722 * linear(color.blue());
}
double contrastRatio(const QColor &first, const QColor &second)
{
const double a = relativeLuminance(first);
const double b = relativeLuminance(second);
return (qMax(a, b) + 0.05) / (qMin(a, b) + 0.05);
}
QColor blend(const QColor &color, const QColor &target, double amount)
{
const double clamped = qBound(0.0, amount, 1.0);
const auto channel = [clamped](int from, int to) { return qRound(from + (to - from) * clamped); };
return QColor(channel(color.red(), target.red()), channel(color.green(), target.green()),
channel(color.blue(), target.blue()));
}
QColor reachContrast(const QColor &color, const QColor &background, double minRatio)
{
if (contrastRatio(color, background) >= minRatio) {
return color;
}
// Move away from the background: toward whichever pole actually reaches
// further. Black and white cross at a luminance of about 0.18, not 0.5, so
// the midpoint test would pick white on a surface where black is far more
// legible -- and could drag an ink that already cleared the floor to white.
// The hue is kept by interpolating, not by replacing.
const QColor black(Qt::black);
const QColor white(Qt::white);
const QColor pole = contrastRatio(black, background) >= contrastRatio(white, background) ? black : white;
if (contrastRatio(pole, background) < minRatio) {
return pole;
}
// The ratio is monotone along the path from color to pole, so a bisection
// finds the first point that clears the floor. Stepping the opposite way
// from the pole keeps the glyph as close to its palette color as possible.
double low = 0.0;
double high = 1.0;
for (int step = 0; step < CONTRAST_SEARCH_STEPS; ++step) {
const double middle = (low + high) / 2.0;
if (contrastRatio(blend(color, pole, middle), background) >= minRatio) {
high = middle;
} else {
low = middle;
}
}
return blend(color, pole, high);
}
QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix)
{
return reachContrast(blend(text, accent, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb();
}
double defaultAccentMix()
{
return ACCENT_MIX;
}
double labelContrastFloor()
{
return LABEL_CONTRAST_FLOOR;
}
QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis)
{
switch (emphasis) {
case IconEmphasis::Primary:
return reachContrast(color, surface, PRIMARY_CONTRAST).toRgb();
case IconEmphasis::Quiet:
return reachContrast(blend(color, surface, QUIET_BLEND), surface, QUIET_CONTRAST_FLOOR).toRgb();
case IconEmphasis::Secondary:
break;
}
return color;
}
double primaryContrastFloor()
{
return PRIMARY_CONTRAST;
}
double quietBlend()
{
return QUIET_BLEND;
}
double quietContrastFloor()
{
return QUIET_CONTRAST_FLOOR;
}
} // namespace ThemeIconInk

View file

@ -0,0 +1,127 @@
/**
* @file theme_icon_ink.h
* @ingroup Interface
*/
#ifndef THEME_ICON_INK_H
#define THEME_ICON_INK_H
#include "theme_icon.h"
#include <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();
/**
* @brief Applies an emphasis level to an already-hued glyph color.
*
* Secondary keeps the hue untouched, so it is the no-op the default needs.
* Primary pushes the color to a higher floor than the default, because the
* accent is a mid-tone: on a light scheme it is lighter than the neutral ink and
* on a dark one darker, so only a stronger contrast target keeps the primary
* level above the secondary on both. Quiet
* blends the color toward the surface it sits on and stops at the non-text
* legibility floor, so a labelling glyph recedes without disappearing.
*
* @param color Hue-resolved glyph color.
* @param surface Surface the glyph is drawn on.
* @param emphasis How much attention the glyph claims.
* @return The emphasized color, as an RGB color.
*/
QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis);
/** @brief Contrast floor the primary level reaches, so it out-reads the default. */
double primaryContrastFloor();
/** @brief How far a quiet glyph is blended into its surface. */
double quietBlend();
/** @brief Contrast floor a quiet glyph keeps, the WCAG non-text minimum. */
double quietContrastFloor();
} // namespace ThemeIconInk
#endif // THEME_ICON_INK_H

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,6 +76,20 @@ int countOpaquePixels(const QPixmap &pixmap, const QColor &expected)
return count;
}
/**
* @brief The ink the engine resolves a neutral glyph to, for @p palette.
*
* Neutral glyphs are not painted with WindowText verbatim: theme_icon_ink
* borrows accent chroma and holds the result to a contrast floor. Tests that
* pin the rendered color ask the rule instead of restating it.
*/
QColor expectedNeutralInk(const QPalette &palette)
{
return ThemeIconInk::glyphInk(palette.color(QPalette::Active, QPalette::WindowText),
palette.color(QPalette::Active, QPalette::Window),
palette.color(QPalette::Active, QPalette::Highlight));
}
/** @brief Opaque pixels that are not the given rim color, i.e. the gradient fill. */
int countOpaquePixelsOtherThan(const QPixmap &pixmap, const QColor &excluded)
{
@ -152,11 +167,12 @@ TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr)
const QIcon icon = loadThemedIcon(QStringLiteral("icons/search"));
ASSERT_FALSE(icon.isNull());
const QColor firstInk = expectedNeutralInk(qApp->palette());
const QPixmap normal = icon.pixmap(QSize(24, 24), 1.0);
ASSERT_FALSE(normal.isNull());
EXPECT_EQ(normal.size(), QSize(24, 24));
EXPECT_DOUBLE_EQ(normal.devicePixelRatio(), 1.0);
EXPECT_GT(countOpaquePixels(normal, QColor(QStringLiteral("#123456"))), 0);
EXPECT_GT(countOpaquePixels(normal, firstInk), 0);
const QPixmap hidpi = icon.pixmap(QSize(24, 24), 2.0);
ASSERT_FALSE(hidpi.isNull());
@ -165,8 +181,10 @@ TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr)
palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#abcdef")));
qApp->setPalette(palette);
const QColor secondInk = expectedNeutralInk(qApp->palette());
EXPECT_NE(firstInk, secondInk) << "the icon did not follow the palette";
const QPixmap flipped = icon.pixmap(QSize(24, 24), 1.0);
EXPECT_GT(countOpaquePixels(flipped, QColor(QStringLiteral("#abcdef"))), 0);
EXPECT_GT(countOpaquePixels(flipped, secondInk), 0);
QPalette selectedPalette = qApp->palette();
selectedPalette.setColor(QPalette::HighlightedText, QColor(QStringLiteral("#00ff00")));
@ -179,6 +197,7 @@ TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr)
TEST_F(ThemeIconRenderTest, SentinelUsesPaletteColor)
{
QPalette palette;
palette.setColor(QPalette::Window, QColor(Qt::black));
palette.setColor(QPalette::WindowText, QColor(Qt::white));
qApp->setPalette(palette);
@ -186,7 +205,7 @@ TEST_F(ThemeIconRenderTest, SentinelUsesPaletteColor)
ASSERT_FALSE(pixmap.isNull());
EXPECT_EQ(pixmap.size(), QSize(24, 24));
EXPECT_GT(countOpaquePixels(pixmap, Qt::white), 0);
EXPECT_GT(countOpaquePixels(pixmap, expectedNeutralInk(qApp->palette())), 0);
}
TEST_F(ThemeIconRenderTest, NonSentinelSvgReturnsNull)
@ -221,7 +240,7 @@ TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved)
const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("lower.svg"));
ASSERT_FALSE(pixmap.isNull());
EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#123456"))), 0);
EXPECT_GT(countOpaquePixels(pixmap, expectedNeutralInk(qApp->palette())), 0);
}
/**
@ -279,7 +298,12 @@ TEST_F(ThemeIconRenderTest, SelectedPaletteTintUsesHighlightedText)
qApp->setPalette(palette);
const IconTint tint(QPalette::WindowText);
EXPECT_EQ(tint.resolve(QIcon::Normal), activeText);
// The normal ink is derived from the active text color, but it is icon ink
// rather than the text color itself.
EXPECT_NE(tint.resolve(QIcon::Normal), activeText);
EXPECT_EQ(tint.resolve(QIcon::Normal), expectedNeutralInk(qApp->palette()));
// A selected row paints on the highlight, so the ink is the palette's
// highlighted text rather than the icon ink.
EXPECT_EQ(tint.resolve(QIcon::Selected), selectedText);
}
@ -1097,3 +1121,519 @@ TEST(IconBadgePreview, WritesPngsWhenRequested)
ASSERT_TRUE(image.save(path)) << path.toStdString();
}
}
namespace
{
/** @brief Palette roles the ink rule reads, from one shipped theme file. */
struct InkPalette
{
QColor text;
QColor window;
QColor button;
QColor accent;
};
/**
* @brief Loads a shipped palette file, or returns a default-constructed palette.
*
* The theme files are the only place the shipped values live, so the rule is
* tested against them rather than against a copy of them.
*/
InkPalette shippedPalette(const QString &theme, bool dark)
{
const QString file =
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR))
.absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml")
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
const PaletteConfig config = PaletteConfig::fromFile(file);
if (!config.hasPalette()) {
return {};
}
const QPalette palette = config.apply(QPalette());
return {palette.color(QPalette::Active, QPalette::WindowText), palette.color(QPalette::Active, QPalette::Window),
palette.color(QPalette::Active, QPalette::Button), palette.color(QPalette::Active, QPalette::Highlight)};
}
} // namespace
TEST(IconInk, GivesNeutralGlyphsAccentChroma)
{
const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), false);
ASSERT_TRUE(palette.text.isValid());
const QColor ink = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent);
EXPECT_NE(ink, palette.text) << "a glyph must not be painted with body-text ink verbatim";
// The borrowed accent is a lighter color than the near-black text ink, so
// the glyph gets a little lighter as well as more chromatic. What must not
// change is its legibility, its blue cast, and the fact that it reads as a
// richer color than plain text.
EXPECT_GT(ink.hsvSaturation(), palette.text.hsvSaturation()) << "the glyph gained no chroma";
EXPECT_GT(ink.blue(), ink.red()) << "accent chroma did not reach the glyph";
EXPECT_GE(ThemeIconInk::contrastRatio(ink, palette.window), ThemeIconInk::labelContrastFloor());
}
TEST(IconInk, MeetsTheLabelFloorOnEveryShippedPalette)
{
const QString themesRoot =
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes"));
const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
ASSERT_FALSE(themes.isEmpty());
int checked = 0;
for (const QString &theme : themes) {
for (const bool dark : {false, true}) {
const InkPalette palette = shippedPalette(theme, dark);
if (!palette.text.isValid()) {
continue;
}
++checked;
const QColor ink = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent);
EXPECT_GE(ThemeIconInk::contrastRatio(ink, palette.window), ThemeIconInk::labelContrastFloor())
<< theme.toStdString() << (dark ? " dark" : " light");
}
}
EXPECT_GT(checked, 0) << "no shipped palette was loadable";
}
TEST(IconInk, KeepsADeliberateHueWhenTheTextColorIsWeak)
{
// A theme whose text color is deliberately low-contrast still gets a glyph
// that clears the floor, and the hue survives the adjustment.
const QColor surface(QStringLiteral("#f4f4f4"));
const QColor weakText(QStringLiteral("#c9c9c9"));
const QColor ink = ThemeIconInk::glyphInk(weakText, surface, QColor(QStringLiteral("#c9c9c9")));
EXPECT_GE(ThemeIconInk::contrastRatio(ink, surface), ThemeIconInk::labelContrastFloor());
EXPECT_LT(ink.value(), weakText.value());
}
/**
* @brief Writes the glyph-ink comparison sheet when asked; skipped otherwise.
*
* One row per accent share, so the trade-off between "still neutral chrome" and
* "clearly an icon" can be judged against the body text drawn beside the glyph.
* Lives in the test binary so the colors come from the same rule the engine uses.
*/
TEST(IconInkPreview, WritesPngsWhenRequested)
{
qputenv("QT_QPA_PLATFORM", "offscreen");
ensureGuiApplication();
const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR");
if (outDir.isEmpty()) {
GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs";
}
ASSERT_TRUE(QDir().mkpath(outDir));
constexpr const char *GLYPHS[] = {"search", "info", "settings", "filter", "increment"};
constexpr int SIZES[] = {15, 16, 24, 48};
constexpr int LAST_SIZE = SIZES[std::size(SIZES) - 1];
constexpr int CELL_GAP = 10;
const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) {
QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()));
QPixmap pixmap(size);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
renderer.render(&painter);
return pixmap;
};
constexpr int MARGIN = 20;
constexpr int SIZE_LABEL_WIDTH = 46;
constexpr int BODY_WIDTH = 46;
constexpr int COLUMN_GAP = 28;
constexpr int ROW_HEIGHT = 58;
constexpr qreal DPR = 2.0;
// The same five glyphs at the sizes the client actually draws them, so the
// comparison is one the reviewer can read rather than a color swatch.
const int glyphsWidth =
static_cast<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();
}
}
namespace
{
/** @brief Applies a shipped palette file to the application for a test. */
bool applyShippedPalette(const QString &theme, bool dark)
{
const QString file =
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR))
.absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml")
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
const PaletteConfig config = PaletteConfig::fromFile(file);
if (!config.hasPalette()) {
return false;
}
qApp->setPalette(config.apply(QPalette()));
return true;
}
QColor surfaceColor()
{
return qApp->palette().color(QPalette::Active, QPalette::Window);
}
} // namespace
/**
* @brief The primary action of a surface takes the accent, not a darker gray.
*
* A glyph hierarchy cannot be built out of "dark" and "darker": at any usable
* contrast a neutral ink is already near the palette's pole, so the top level
* has to be a different color. The accent is the palette's own answer to "which
* color means action", so primary uses it instead of inventing a token.
*/
TEST_F(ThemeIconRenderTest, PrimaryEmphasisUsesTheAccent)
{
ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false));
const QColor secondary = IconTint().resolve(QIcon::Normal);
const QColor primary = IconTint::primary().resolve(QIcon::Normal);
EXPECT_NE(primary, secondary);
EXPECT_GT(primary.blue(), primary.red()) << "the primary glyph did not take the accent's blue";
EXPECT_GE(ThemeIconInk::contrastRatio(primary, surfaceColor()), ThemeIconInk::primaryContrastFloor());
EXPECT_GE(ThemeIconInk::contrastRatio(primary, surfaceColor()),
ThemeIconInk::contrastRatio(secondary, surfaceColor()));
}
/**
* @brief The primary level out-reads the default one on both schemes.
*
* The default ink borrows enough accent to sit off the palette's pole (5.4:1 on
* light, 6.4:1 on dark), so the 7:1 primary floor is measurably above it in both
* schemes instead of being lost against a near-black or near-white default.
* Hue alone would not keep the order; the contrast target does.
*/
TEST_F(ThemeIconRenderTest, PrimaryOutReadsSecondaryOnBothSchemes)
{
for (const bool dark : {false, true}) {
ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), dark));
const QColor surface = surfaceColor();
const QColor secondary = IconTint().resolve(QIcon::Normal);
const QColor primary = IconTint::primary().resolve(QIcon::Normal);
EXPECT_NE(primary, secondary) << (dark ? "dark" : "light");
EXPECT_GT(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::contrastRatio(secondary, surface))
<< (dark ? "dark" : "light");
EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor())
<< (dark ? "dark" : "light");
}
}
/** @brief A quiet glyph recedes toward its surface without dropping below 3:1. */
TEST_F(ThemeIconRenderTest, QuietEmphasisRecedesButStaysLegible)
{
ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false));
const QColor secondary = IconTint().resolve(QIcon::Normal);
const QColor quiet = IconTint::quiet().resolve(QIcon::Normal);
EXPECT_LT(ThemeIconInk::contrastRatio(quiet, surfaceColor()),
ThemeIconInk::contrastRatio(secondary, surfaceColor()))
<< "the quiet glyph does not recede";
EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surfaceColor()), ThemeIconInk::quietContrastFloor());
}
/** @brief Emphasis changes how loud a semantic color is, never which color it is. */
TEST_F(ThemeIconRenderTest, EmphasisKeepsASemanticHue)
{
ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false));
for (const IconEmphasis emphasis : {IconEmphasis::Primary, IconEmphasis::Secondary, IconEmphasis::Quiet}) {
const QColor ink = IconTint(AppColor::Positive, emphasis).resolve(QIcon::Normal);
EXPECT_GT(ink.green(), ink.red()) << static_cast<int>(emphasis);
EXPECT_GT(ink.green(), ink.blue()) << static_cast<int>(emphasis);
}
}
/** @brief Every emphasis keeps the selected-row swap to HighlightedText. */
TEST_F(ThemeIconRenderTest, SelectedStillFallsBackToHighlightedText)
{
QPalette palette = qApp->palette();
const QColor selectedText(QStringLiteral("#222222"));
palette.setColor(QPalette::Active, QPalette::Highlight, QColor(QStringLiteral("#000000")));
palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText);
qApp->setPalette(palette);
// Black ink on a black highlight is illegible, so the selection wins for
// both the default and the quiet level.
EXPECT_EQ(IconTint().resolve(QIcon::Selected), selectedText);
EXPECT_EQ(IconTint::quiet().resolve(QIcon::Selected), selectedText);
}
/**
* @brief Writes the emphasis ladder sheets for every shipped palette theme.
*
* One sheet per theme and scheme: the neutral, positive and negative families at
* their quiet, secondary and primary levels, each as a line glyph, a sticker and
* a badge, three glyphs per family, at 16 and 24 px, on the window and button
* surfaces. The tone is the one the emphasis rule resolves for that surface, so
* the sheet is what the engine paints rather than a hand-picked sample.
*/
TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested)
{
const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR");
if (outDir.isEmpty()) {
GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs";
}
ASSERT_TRUE(QDir().mkpath(outDir));
constexpr int MARGIN = 24;
constexpr int LABEL_WIDTH = 230;
constexpr int CELL_WIDTH = 166;
constexpr int ROW_HEIGHT = 58;
constexpr int PANEL_TITLE = 26;
constexpr int COLUMN_HEADER = 20;
constexpr int PANEL_PADDING = 12;
constexpr int GLYPH_SLOT = 32;
constexpr qreal DPR = 2.0;
constexpr int SIZES[] = {16, 24};
constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"};
constexpr const char *LEVEL_NAMES[] = {"Quiet", "Secondary", "Primary"};
constexpr IconEmphasis LEVELS[] = {IconEmphasis::Quiet, IconEmphasis::Secondary, IconEmphasis::Primary};
struct Family
{
const char *name;
const char *glyphs[3];
AppColor::Role role;
bool neutral;
};
const QList<Family> families = {
{"Neutral", {"settings", "info", "player"}, AppColor::Positive, true},
{"Positive", {"increment", "ready_start", "arrow_up"}, AppColor::Positive, false},
{"Negative", {"decrement", "not_ready_start", "remove_row"}, AppColor::Negative, false},
};
const QStringList themes = {
QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"),
QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"),
};
const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) {
QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1()));
QPixmap pixmap(size);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
renderer.render(&painter);
return pixmap;
};
const int panelWidth = LABEL_WIDTH + static_cast<int>(std::size(TREATMENTS)) * CELL_WIDTH + PANEL_PADDING;
const int panelHeight =
PANEL_TITLE + COLUMN_HEADER + static_cast<int>(families.size()) * 3 * ROW_HEIGHT + PANEL_PADDING;
const int logicalWidth = 2 * MARGIN + panelWidth;
const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN;
for (const QString &theme : themes) {
for (const bool dark : {false, true}) {
const InkPalette palette = shippedPalette(theme, dark);
ASSERT_TRUE(palette.text.isValid()) << theme.toStdString();
// The emphasis rule reads the live palette, so apply the theme
// before resolving tones for the sheet.
ASSERT_TRUE(applyShippedPalette(theme, dark));
const auto toneFor = [&](const Family &family, int level) {
QColor base;
if (family.neutral) {
base = level == 2 ? palette.accent
: ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent);
} else {
base = ThemeIconFill::saturated(defaultAppColor(family.role, dark));
}
return ThemeIconInk::forEmphasis(base, palette.window, LEVELS[level]);
};
QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)),
QImage::Format_ARGB32_Premultiplied);
image.fill(palette.window);
QPainter painter(&image);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.scale(DPR, DPR);
painter.setPen(palette.text);
painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold));
painter.drawText(QPointF(MARGIN, MARGIN),
QStringLiteral("%1 — %2 scheme, emphasis across treatments")
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
struct Surface
{
const char *name;
QColor background;
QColor text;
};
const QList<Surface> surfaces = {
{"Window surface", palette.window, palette.text},
{"Button surface", palette.button, palette.text},
};
qreal panelTop = MARGIN + 30;
for (const Surface &surface : surfaces) {
painter.setPen(Qt::NoPen);
painter.setBrush(surface.background);
painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8);
const qreal contentLeft = MARGIN + PANEL_PADDING;
painter.setPen(surface.text);
painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold));
painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name));
painter.setFont(QFont(QStringLiteral("Sans"), 8));
for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) {
const qreal x = contentLeft + LABEL_WIDTH + static_cast<qreal>(treatment) * CELL_WIDTH;
painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14),
QString::fromLatin1(TREATMENTS[treatment]) + QStringLiteral(" 16 / 24"));
}
qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER;
for (const Family &family : families) {
for (int level = 0; level < 3; ++level) {
const QColor ink = toneFor(family, level);
ASSERT_TRUE(ink.isValid())
<< theme.toStdString() << " " << family.name << " " << LEVEL_NAMES[level];
painter.setPen(surface.text);
painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold));
painter.drawText(QPointF(contentLeft, y + 14),
QStringLiteral("%1 / %2").arg(QString::fromLatin1(family.name),
QString::fromLatin1(LEVEL_NAMES[level])));
painter.setFont(QFont(QStringLiteral("Mono"), 7));
painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb));
for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) {
const qreal cellX = contentLeft + LABEL_WIDTH + static_cast<qreal>(treatment) * CELL_WIDTH;
for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) {
const int side = SIZES[sizeIndex];
const QSize size(side, side);
const qreal rowTop = y + (sizeIndex == 0 ? 4 : 28);
for (std::size_t glyphIndex = 0; glyphIndex < std::size(family.glyphs); ++glyphIndex) {
const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]);
ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex];
QPixmap pixmap;
if (treatment == 0) {
pixmap = renderLine(svg, ink, size);
} else if (treatment == 1) {
pixmap = ThemeIconSticker::renderSticker(svg, ink, size);
} else {
pixmap = ThemeIconBadge::renderBadge(svg, ink, size);
}
const qreal x = cellX + 6 + static_cast<qreal>(glyphIndex) * GLYPH_SLOT +
(GLYPH_SLOT - side) / 2.0;
painter.drawPixmap(QPointF(x, rowTop + (GLYPH_SLOT - side) / 2.0), pixmap);
}
}
}
y += ROW_HEIGHT;
}
}
panelTop += panelHeight + 24;
}
painter.end();
const QString path = QDir(outDir).absoluteFilePath(
QStringLiteral("emphasis-preview-%1-%2.png")
.arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light")));
ASSERT_TRUE(image.save(path)) << path.toStdString();
}
}
}