[Icons] Keep the authored emphasis in filled treatments

The saturation floor was added while the emphasis levels were still
computed: it rescued the pale dark-scheme tones once a whole shape
carried them. With the levels authored, the floor overrides the author
instead: a quiet level jumped to the secondary saturation as soon as a
sticker or a badge painted it, so the ladder only existed for line
glyphs.

The authored tone is now the top of the fill gradient and the treatment
only darkens the bottom for depth, so every treatment renders exactly the
level the palette stores. The badge glyph ink no longer guesses the
scheme from the tone's value: an authored quiet level is a dark color on
the dark scheme too, so the scheme is passed in from the surface palette.
The preview sheet renders every shipped palette theme, three glyphs per
family, and each row prints the authored tone and the darker bottom.
This commit is contained in:
Lukas Brübach 2026-11-07 01:21:20 +01:00
parent 03a4a33ba5
commit 2e4731d3a5
8 changed files with 346 additions and 171 deletions

View file

@ -30,6 +30,49 @@
namespace
{
/**
* @brief The role an emphasis level selects inside a base role's family.
*
* The theme authors all three levels, so emphasis is a lookup instead of a
* color computation. Naming a variant (`IconStrong`, `PositiveSoft`) is an
* explicit request and keeps that role at every emphasis; only the base roles
* take part in the mapping. Accent roles have no neutral middle member, so they
* are always used as named.
*/
AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis)
{
switch (role) {
case AppColor::Icon:
return emphasis == IconEmphasis::Primary ? AppColor::IconStrong
: emphasis == IconEmphasis::Quiet ? AppColor::IconSoft
: AppColor::Icon;
case AppColor::Positive:
return emphasis == IconEmphasis::Primary ? AppColor::PositiveStrong
: emphasis == IconEmphasis::Quiet ? AppColor::PositiveSoft
: AppColor::Positive;
case AppColor::Negative:
return emphasis == IconEmphasis::Primary ? AppColor::NegativeStrong
: emphasis == IconEmphasis::Quiet ? AppColor::NegativeSoft
: AppColor::Negative;
default:
return role;
}
}
/**
* @brief The scheme the active application palette is painting for.
*
* The fill needs the surface scheme to choose the badge glyph ink, and it cannot
* read it from the tone: an authored quiet level is a dark color on the dark
* scheme too.
*/
ThemeIconFill::Scheme activeScheme()
{
const QPalette &palette = qApp->palette();
return palette.color(QPalette::Active, QPalette::Window).lightness() < 128 ? ThemeIconFill::Scheme::Dark
: ThemeIconFill::Scheme::Light;
}
/**
* @brief Reads an SVG through the `theme:` search path.
*/
@ -264,7 +307,7 @@ private:
QByteArray variant;
if (badge) {
variant = QByteArrayLiteral("B|") + tone.name(QColor::HexRgb).toLatin1() + '|' +
ThemeIconBadge::glyphColor(tone).name(QColor::HexRgb).toLatin1();
ThemeIconBadge::glyphColor(tone, activeScheme()).name(QColor::HexRgb).toLatin1();
} else if (sticker) {
variant = QByteArrayLiteral("S|") + tone.name(QColor::HexRgb).toLatin1();
} else if (resolvedHasSentinel) {
@ -282,7 +325,7 @@ private:
if (badge) {
// Geometry is computed from the device-pixel size, and the ratio is
// attached below like every other path.
pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize);
pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize, activeScheme());
} else if (sticker) {
pixmap = ThemeIconSticker::renderSticker(resolvedSvg, tone, pixelSize);
} else if (resolvedHasSentinel) {
@ -318,34 +361,6 @@ private:
mutable bool hasIntrinsicSize = false;
};
/**
* @brief The role an emphasis level selects inside a base role's family.
*
* The theme authors all three levels, so emphasis is a lookup instead of a
* color computation. Naming a variant (`IconStrong`, `PositiveSoft`) is an
* explicit request and keeps that role at every emphasis; only the base roles
* take part in the mapping. Accent roles have no neutral middle member, so they
* are always used as named.
*/
AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis)
{
switch (role) {
case AppColor::Icon:
return emphasis == IconEmphasis::Primary ? AppColor::IconStrong
: emphasis == IconEmphasis::Quiet ? AppColor::IconSoft
: AppColor::Icon;
case AppColor::Positive:
return emphasis == IconEmphasis::Primary ? AppColor::PositiveStrong
: emphasis == IconEmphasis::Quiet ? AppColor::PositiveSoft
: AppColor::Positive;
case AppColor::Negative:
return emphasis == IconEmphasis::Primary ? AppColor::NegativeStrong
: emphasis == IconEmphasis::Quiet ? AppColor::NegativeSoft
: AppColor::Negative;
default:
return role;
}
}
} // namespace
IconTint::IconTint(QPalette::ColorRole role, IconEmphasis _emphasis) : paletteRole(role), emphasis(_emphasis)
@ -378,9 +393,9 @@ QColor IconTint::resolve(QIcon::Mode mode) const
return palette.color(QPalette::Disabled, QPalette::WindowText);
}
// The theme authors one color per emphasis level, so the paint path
// uses the resolved role verbatim. The filled treatments apply their
// own saturation floor when a disc or a fill needs mass; a line glyph
// keeps the color the author picked.
// uses the resolved role verbatim. The filled treatments only shape the
// tone's value to give a disc or a fill depth; hue and saturation stay
// the author's.
const QColor color = themeManager->appColor(emphasisRole(appRole, emphasis));
if (mode != QIcon::Selected) {
return color;
@ -428,7 +443,7 @@ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const
if (canvas.isEmpty()) {
canvas = QSvgRenderer(svg).defaultSize();
}
return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas)
return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas, activeScheme())
: ThemeIconSticker::renderSticker(svg, tone, canvas);
}

View file

@ -99,8 +99,8 @@ public:
*
* Application colors are returned verbatim: the theme author stores one
* value per emphasis level, so the paint path does not rewrite it. The
* filled treatments apply their own saturation floor to a fill they have to
* carry; a line glyph keeps the author's color.
* filled treatments only shape that tone's value to give a shape depth; the
* hue and saturation stay the author's.
*
* Neutral label roles (WindowText, Text) resolve to icon ink rather than the
* raw palette color: ThemeIconInk::glyphInk derives it from the accent and

View file

@ -46,16 +46,16 @@ DiscFill discFill(const QColor &tone)
namespace ThemeIconBadge
{
QColor glyphColor(const QColor &tone)
QColor glyphColor(const QColor &tone, ThemeIconFill::Scheme scheme)
{
// The badge carries the ink the pre-sticker badge did: white on the light
// scheme, and the tone darkened to an outline-dark ink on the dark scheme.
// The shared saturated disc is light enough there that white sits near
// 2.7:1 at the top, so the dark ink is the deliberate choice rather than a
// contrast measurement that a palette tweak could flip. Deriving it from
// the tone keeps themes in control.
return ThemeIconFill::isDarkSchemeTone(tone) ? ThemeIconFill::saturated(tone).darker(GLYPH_DARKER)
: QColor(Qt::white);
// The lifted disc is light enough there that white sits near 2.7:1 at the
// top, so the dark ink is the deliberate choice rather than a contrast
// measurement that a palette tweak could flip. Deriving it from the tone
// keeps themes in control, and the scheme is explicit because an authored
// quiet level is a dark tone on the dark scheme too.
return scheme == ThemeIconFill::Scheme::Dark ? tone.darker(GLYPH_DARKER) : QColor(Qt::white);
}
QSize glyphSize(const QSize &pixelSize)
@ -70,7 +70,7 @@ QSize glyphSize(const QSize &pixelSize)
return QSize(glyphSide, glyphSide);
}
QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize)
QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize, ThemeIconFill::Scheme scheme)
{
if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) {
return {};
@ -100,12 +100,12 @@ QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixe
// The ring closes the disc: its outer edge touches the canvas, its inner
// edge is the disc boundary, so the two meet without a seam.
painter.setBrush(Qt::NoBrush);
painter.setPen(QPen(ThemeIconFill::saturated(tone).darker(RING_DARKER), ringWidth));
painter.setPen(QPen(tone.darker(RING_DARKER), ringWidth));
const qreal ringRadius = (side - ringWidth) / 2.0;
painter.drawEllipse(discRect.center(), ringRadius, ringRadius);
const QByteArray glyphSvg = resolveSvgStrokeWidth(
resolveSvgColorSentinel(svg, glyphColor(tone).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH);
resolveSvgColorSentinel(svg, glyphColor(tone, scheme).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH);
QSvgRenderer renderer(glyphSvg);
if (renderer.isValid()) {
const QSize size = glyphSize(pixelSize);

View file

@ -6,6 +6,8 @@
#ifndef THEME_ICON_BADGE_H
#define THEME_ICON_BADGE_H
#include "theme_icon_fill.h"
#include <QByteArray>
#include <QColor>
#include <QPixmap>
@ -34,13 +36,17 @@ namespace ThemeIconBadge
/**
* @brief Glyph color for a badge disc.
*
* White on the light scheme, the saturated tone darkened to an outline-dark ink
* White on the light scheme, the authored tone darkened to an outline-dark ink
* on the dark scheme. The dark-scheme tones fill the disc light enough that
* white would sit near 2.7:1 at its top, so that scheme keeps the darkened ink
* the badge had before the filled treatments shared a fill. The dark ink is
* derived from the tone, so the theme keeps control of both ends.
* the badge had before the filled treatments shared a fill. The scheme is passed
* in rather than inferred from the tone, because an authored quiet level is a
* dark color on the dark scheme as well.
*
* @param tone Disc color, already resolved for the icon mode.
* @param scheme Scheme the badge is painted for.
*/
QColor glyphColor(const QColor &tone);
QColor glyphColor(const QColor &tone, ThemeIconFill::Scheme scheme);
/** @brief Size of the engraved glyph for a badge of @p pixelSize, in device pixels. */
QSize glyphSize(const QSize &pixelSize);
@ -63,9 +69,10 @@ QSize glyphSize(const QSize &pixelSize);
* @param tone Disc color, already resolved for the icon mode.
* @param pixelSize Canvas size in device pixels; a non-square canvas centers
* the badge and leaves the remaining area transparent.
* @param scheme Scheme the badge is painted for; selects the glyph ink.
* @return The composited badge, or a null pixmap for empty or invalid input.
*/
QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize);
QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize, ThemeIconFill::Scheme scheme);
} // namespace ThemeIconBadge
#endif // THEME_ICON_BADGE_H

View file

@ -1,61 +1,22 @@
#include "theme_icon_fill.h"
#include <QtGlobal>
namespace
{
// Semantic tones are tuned for text. The dark-scheme ones are deliberately
// desaturated, which reads washed out when the same color fills a whole shape,
// so the fill gives its tone a saturation floor.
constexpr int MIN_SATURATION = 170;
// A tone this light belongs to the dark scheme; filling a glyph with it at full
// value is what looks pale, so its gradient is pulled down instead of up.
constexpr int LIGHT_TONE_VALUE = 200;
constexpr int MIN_VALUE = 200;
// The bottom end of every filled treatment. The authored tone is the top, so a
// single darkening keeps the emphasis level the palette author picked and still
// gives the shape the lit-from-above depth the pre-#7595 art had.
constexpr int GRADIENT_BOTTOM_DARKER = 140;
constexpr int DARK_SCHEME_TOP_DARKER = 120;
constexpr int DARK_SCHEME_BOTTOM_DARKER = 170;
} // namespace
namespace
{
/** @brief Raises a tone's saturation, and optionally its value, for a filled shape. */
QColor boost(const QColor &color, int minValue)
{
if (color.hue() < 0 || (color.hsvSaturation() >= MIN_SATURATION && color.value() >= minValue)) {
return color;
}
QColor result = color;
result.setHsv(color.hue(), qMax(color.hsvSaturation(), MIN_SATURATION), qMax(color.value(), minValue));
// Return RGB so callers comparing or caching the tone do not see a spec
// change for an unchanged color.
return result.toRgb();
}
} // namespace
namespace ThemeIconFill
{
QColor saturated(const QColor &tone)
{
return boost(tone, 0);
}
bool isDarkSchemeTone(const QColor &tone)
{
return tone.value() >= LIGHT_TONE_VALUE;
}
QColor topColor(const QColor &tone)
{
if (isDarkSchemeTone(tone)) {
return boost(tone, 0).darker(DARK_SCHEME_TOP_DARKER);
}
return boost(tone, MIN_VALUE);
return tone;
}
QColor bottomColor(const QColor &tone)
{
const int darker = isDarkSchemeTone(tone) ? DARK_SCHEME_BOTTOM_DARKER : GRADIENT_BOTTOM_DARKER;
return boost(tone, 0).darker(darker);
return tone.darker(GRADIENT_BOTTOM_DARKER);
}
} // namespace ThemeIconFill

View file

@ -12,50 +12,41 @@
* @brief Picks the two colors of a filled treatment's vertical gradient.
*
* The pre-#7595 icons were filled with a lighter top over a darker bottom. The
* semantic tones are tuned for text, not for filling a whole shape; the
* dark-scheme tones in particular are desaturated and read washed out once a
* disc or a glyph carries them. The badge and the sticker share one fill rule so
* they sit next to each other with the same green or red.
* badge and the sticker share one fill rule so they sit next to each other with
* the same green or red.
*
* The rule needs no scheme switch: a tone whose value is already high is a
* dark-scheme tone, so its gradient is pulled down. Filling a shape with a tone
* that light is what reads pale.
* The authored tone is the top of that gradient: the palette author stores one
* color per emphasis level, so the treatment only adds depth by darkening the
* bottom. Hue, saturation and value all stay the author's, so the rendered
* ladder is the authored ladder on every scheme.
*/
namespace ThemeIconFill
{
/**
* @brief Returns the tone with a saturation floor, for a filled shape.
* @brief Which scheme a treatment is painted for.
*
* The hue and value are left alone, so a light-scheme tone stays as dark as the
* line glyph next to it, and an achromatic tone stays gray.
*
* @param tone Glyph color the treatment is rendered with.
* @return The tone, saturated enough to carry a whole shape.
* The scheme is a property of the surface, not of the tone: an authored quiet
* level is deliberately darker than its secondary level on the dark scheme too,
* so it cannot be inferred from the tone's value. Callers know the surface
* palette and pass it explicitly.
*/
QColor saturated(const QColor &tone);
/**
* @brief True when @p tone is a light tone, as the dark scheme's palette is.
*
* The fill branches on this: a tone this light is darkened for a filled shape,
* and the badge keeps its darkened ink on it instead of the light scheme's
* white glyph.
*
* @param tone Glyph color the treatment is rendered with.
*/
bool isDarkSchemeTone(const QColor &tone);
enum class Scheme
{
Light,
Dark,
};
/**
* @brief Returns the top color of the fill gradient.
* @param tone Glyph color the treatment is rendered with.
* @return The lighter end of the gradient.
* @return The authored tone, unchanged.
*/
QColor topColor(const QColor &tone);
/**
* @brief Returns the bottom color of the fill gradient.
* @param tone Glyph color the treatment is rendered with.
* @return The darker end of the gradient.
* @return The authored tone, darkened for depth.
*/
QColor bottomColor(const QColor &tone);
} // namespace ThemeIconFill

View file

@ -30,7 +30,7 @@ namespace ThemeIconSticker
{
QColor outlineColor(const QColor &tone)
{
return ThemeIconFill::saturated(tone).darker(OUTLINE_DARKER);
return tone.darker(OUTLINE_DARKER);
}
QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize)

View file

@ -336,9 +336,8 @@ TEST_F(ThemeIconRenderTest, SelectedAppColorKeepsAContrastingTint)
* @brief A line glyph renders the authored role verbatim.
*
* The emphasis colors are authored data now, so the paint path must not
* rewrite them. A muted quiet role stays muted on a line glyph; a badge disc or
* a sticker fill boosts its own fill, because carrying a whole shape is a
* treatment concern rather than a role concern.
* rewrite them. A muted quiet role stays muted on a line glyph, and the badge
* and sticker only shape the value of that tone to give the shape depth.
*/
TEST_F(ThemeIconRenderTest, LineTintRendersTheAuthoredRoleVerbatim)
{
@ -352,7 +351,6 @@ TEST_F(ThemeIconRenderTest, LineTintRendersTheAuthoredRoleVerbatim)
const QPixmap pixmap = icon.pixmap(QSize(24, 24));
ASSERT_FALSE(pixmap.isNull());
EXPECT_GT(countOpaquePixels(pixmap, raw), 0);
EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(raw)), 0);
}
/** @brief A badge is a disc with a gradient fill, a dark ring and a contrast glyph. */
@ -371,8 +369,8 @@ TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing)
EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0);
EXPECT_GT(image.pixelColor(32, 32).alpha(), 200);
// The ring is the saturated tone darkened, and it must actually be painted.
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0);
// The ring is the authored tone darkened, and it must actually be painted.
EXPECT_GT(countOpaquePixels(pixmap, tone.darker(300)), 0);
// The fill runs from a lighter top to a darker bottom. Sample left of the
// check, which now reaches almost to the disc edge.
@ -384,40 +382,45 @@ TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing)
EXPECT_GT(top.lightness(), bottom.lightness());
// The glyph uses the contrast color chosen for this tone.
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0);
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 0);
}
/**
* @brief The dark-scheme disc carries the same saturated fill as the sticker.
* @brief The dark-scheme disc keeps the authored tone and darkens it.
*
* The dark tones are tuned for text and read as pale mint or salmon once they
* fill a disc, so the badge saturates its fill. The dark scheme keeps the
* darkened ink the badge had before the fill moved to the sticker ramp.
* The authored tone is the top of the gradient, so the level the palette author
* picked survives the treatment; the shape only adds depth by darkening the
* bottom. It must not re-saturate the tone: the authored saturation is the theme
* author's choice. The dark scheme keeps the darkened ink the badge had before
* the fill moved to the sticker ramp.
*/
TEST_F(ThemeIconRenderTest, BadgeDarkSchemeDiscUsesTheSaturatedFill)
TEST_F(ThemeIconRenderTest, BadgeDarkSchemeKeepsTheAuthoredTone)
{
const QColor tone = defaultAppColor(AppColor::Positive, true);
ASSERT_TRUE(tone.isValid());
ASSERT_GE(tone.value(), 200);
const QPixmap pixmap = ThemeIconBadge::renderBadge(readIconSvg("ready_start"), tone, QSize(64, 64));
const QPixmap pixmap =
ThemeIconBadge::renderBadge(readIconSvg("ready_start"), tone, QSize(64, 64), ThemeIconFill::Scheme::Dark);
ASSERT_FALSE(pixmap.isNull());
const QImage image = pixmap.toImage();
// The disc neither keeps the pale raw tone nor the old unsaturating bottom.
EXPECT_EQ(countOpaquePixels(pixmap, tone), 0);
EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(104)), 0);
const QColor top = image.pixelColor(10, 16);
const QColor bottom = image.pixelColor(10, 48);
ASSERT_GT(top.alpha(), 200);
ASSERT_GT(bottom.alpha(), 200);
// Antialiasing costs a point or two of saturation at the sample points.
EXPECT_GE(top.hsvSaturation(), 160);
EXPECT_GE(bottom.hsvSaturation(), 160);
// The top is a darkening of the authored tone, never a lift, and it keeps
// the authored saturation; the sample sits a little down the gradient.
EXPECT_LE(top.value(), tone.value());
EXPECT_GT(top.value(), bottom.value());
EXPECT_LT(bottom.value(), tone.value());
EXPECT_GT(top.lightness(), bottom.lightness());
// Antialiasing costs a point or two of saturation at the sample points.
EXPECT_NEAR(top.hsvSaturation(), tone.hsvSaturation(), 24);
EXPECT_NEAR(bottom.hsvSaturation(), tone.hsvSaturation(), 24);
// The dark scheme keeps the darkened ink, and it is actually painted.
const QColor ink = ThemeIconBadge::glyphColor(tone);
const QColor ink = ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Dark);
ASSERT_NE(ink, QColor(Qt::white));
EXPECT_GT(countOpaquePixels(pixmap, ink), 0);
}
@ -426,8 +429,8 @@ TEST_F(ThemeIconRenderTest, BadgeDarkSchemeDiscUsesTheSaturatedFill)
* @brief The badge ink follows the scheme.
*
* Light-scheme tones keep the white glyph of the old art; dark-scheme tones
* keep the darkened ink, because the shared saturated disc is light enough that
* white would sit near 2.7:1 at its top. Pinning both cases stops the glyph from
* keep the darkened ink, because the lifted disc is light enough that white
* would sit near 2.7:1 at its top. Pinning both cases stops the glyph from
* silently flipping back to white when the fill or the palette changes.
*/
TEST_F(ThemeIconRenderTest, BadgeInkFollowsTheScheme)
@ -444,7 +447,8 @@ TEST_F(ThemeIconRenderTest, BadgeInkFollowsTheScheme)
for (const auto &testCase : cases) {
const QColor tone = defaultAppColor(testCase.role, testCase.dark);
ASSERT_TRUE(tone.isValid());
const QColor ink = ThemeIconBadge::glyphColor(tone);
const QColor ink = ThemeIconBadge::glyphColor(tone, testCase.dark ? ThemeIconFill::Scheme::Dark
: ThemeIconFill::Scheme::Light);
if (testCase.dark) {
EXPECT_NE(ink, QColor(Qt::white));
EXPECT_LT(ink.value(), 100);
@ -487,7 +491,7 @@ TEST_F(ThemeIconRenderTest, BadgeStaysLegibleAtPlayerListSize)
EXPECT_GT(image.pixelColor(7, 7).alpha(), 200);
// A 1px ring on a 15px canvas antialiases into the disc and has no exact
// color, so the glyph is what has to stay legible.
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 8);
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 8);
}
/** @brief The pixmap API's badge path agrees with the engine's. */
@ -498,8 +502,8 @@ TEST_F(ThemeIconRenderTest, SentinelSvgBadgeMatchesTheEngine)
QSize(48, 48), IconStyle::Badge);
ASSERT_FALSE(pixmap.isNull());
EXPECT_EQ(pixmap.size(), QSize(48, 48));
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0);
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0);
EXPECT_GT(countOpaquePixels(pixmap, tone.darker(300)), 0);
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 0);
}
/**
@ -576,7 +580,7 @@ TEST_F(ThemeIconRenderTest, BadgeOnAuthoredArtUsesTheAssetPath)
// The authored color survives; no disc tone and no ring are painted.
EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0);
EXPECT_EQ(countOpaquePixels(pixmap, tone), 0);
EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0);
EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0);
}
/** @brief Disabled keeps the shape language but drops back to the line glyph. */
@ -594,7 +598,7 @@ TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph)
// A filled disc at full mass would not read as unavailable, so Disabled
// drops the badge: no ring, no disc, just the disabled line glyph.
EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0);
EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0);
EXPECT_EQ(countOpaquePixels(pixmap, disabledText.darker(300)), 0);
EXPECT_EQ(pixmap.toImage().pixelColor(0, 0).alpha(), 0);
EXPECT_GT(countOpaquePixels(pixmap, disabledText), 0);
@ -631,13 +635,13 @@ TEST_F(ThemeIconRenderTest, StickerPaintsToneFillWithDarkOutline)
}
/**
* @brief The dark-scheme fill is the branch the treatment exists for.
* @brief The dark-scheme fill keeps the authored tone and darkens it.
*
* That tone is light and deliberately desaturated, so a plain gradient paints a
* pale mint shape. The fill has to raise its saturation and pull its value down
* instead of up.
* The authored tone is the top of the gradient, so the level the palette author
* picked survives the treatment. The treatment only adds depth by darkening the
* bottom, without re-saturating the tone.
*/
TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillIsSaturatedAndDarkerThanTheTone)
TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillKeepsTheAuthoredTone)
{
const QColor tone = defaultAppColor(AppColor::Positive, true);
ASSERT_TRUE(tone.isValid());
@ -651,10 +655,14 @@ TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillIsSaturatedAndDarkerThanTheTone
const QColor bottom = image.pixelColor(32, 50);
ASSERT_GT(top.alpha(), 200);
ASSERT_GT(bottom.alpha(), 200);
EXPECT_LT(top.value(), tone.value());
// The top is a darkening of the authored tone, never a lift, and it keeps
// the authored saturation; the sample sits a little down the gradient.
EXPECT_LE(top.value(), tone.value());
EXPECT_GT(top.value(), bottom.value());
EXPECT_LT(bottom.value(), tone.value());
EXPECT_GT(top.lightness(), bottom.lightness());
EXPECT_GE(top.hsvSaturation(), 170);
EXPECT_NEAR(top.hsvSaturation(), tone.hsvSaturation(), 24);
EXPECT_NEAR(bottom.hsvSaturation(), tone.hsvSaturation(), 24);
}
/** @brief The sticker is rendered at the device-pixel size, not upscaled later. */
@ -749,7 +757,7 @@ TEST_F(ThemeIconRenderTest, LineStyleStillRendersGlyphAlone)
loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive).pixmap(QSize(64, 64));
ASSERT_FALSE(pixmap.isNull());
EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0);
EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0);
EXPECT_GT(countOpaquePixels(pixmap, tone), 0);
}
@ -938,8 +946,8 @@ TEST(IconStickerPreview, WritesPngsWhenRequested)
const QByteArray svg = readSvg(glyph.name);
ASSERT_FALSE(svg.isEmpty()) << glyph.name;
// The preview must show the line glyph the engine paints, and the
// engine saturates AppColor tones before drawing them.
const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark));
// engine renders an authored tone verbatim.
const QColor tone = defaultAppColor(glyph.role, dark);
for (int row = 0; row < 2; ++row) {
const bool sticker = row == 1;
@ -972,8 +980,10 @@ TEST(IconStickerPreview, WritesPngsWhenRequested)
ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name;
const QColor tone = defaultAppColor(badgeContext[i].role, dark);
const qreal x = MARGIN + LABEL_WIDTH + static_cast<qreal>(i) * CELL_WIDTH;
painter.drawPixmap(QPointF(x, y + 8),
ThemeIconBadge::renderBadge(svg, tone, QSize(CONTEXT_SIZE, CONTEXT_SIZE)));
painter.drawPixmap(
QPointF(x, y + 8),
ThemeIconBadge::renderBadge(svg, tone, QSize(CONTEXT_SIZE, CONTEXT_SIZE),
dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light));
painter.drawText(QPointF(x, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(badgeContext[i].name));
}
@ -984,7 +994,7 @@ TEST(IconStickerPreview, WritesPngsWhenRequested)
for (std::size_t i = 0; i < std::size(badgeContext); ++i) {
const QByteArray svg = readSvg(badgeContext[i].name);
ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name;
const QColor tone = ThemeIconFill::saturated(defaultAppColor(badgeContext[i].role, dark));
const QColor tone = defaultAppColor(badgeContext[i].role, dark);
const qreal x = MARGIN + LABEL_WIDTH + static_cast<qreal>(i) * CELL_WIDTH * 1.5;
for (int j = 0; j < 3; ++j) {
const QSize side(CONTEXT_SIZE, CONTEXT_SIZE);
@ -992,7 +1002,8 @@ TEST(IconStickerPreview, WritesPngsWhenRequested)
if (j == 0) {
pixmap = renderLine(svg, tone, side);
} else if (j == 1) {
pixmap = ThemeIconBadge::renderBadge(svg, tone, side);
pixmap = ThemeIconBadge::renderBadge(
svg, tone, side, dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light);
} else {
pixmap = ThemeIconSticker::renderSticker(svg, tone, side);
}
@ -1090,8 +1101,8 @@ TEST(IconBadgePreview, WritesPngsWhenRequested)
const QByteArray svg = readSvg(glyph.name);
ASSERT_FALSE(svg.isEmpty()) << glyph.name;
// The preview must show the line glyph the engine paints, and the
// engine saturates AppColor tones before drawing them.
const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark));
// engine renders an authored tone verbatim.
const QColor tone = defaultAppColor(glyph.role, dark);
for (int row = 0; row < 2; ++row) {
const bool badge = row == 1;
@ -1101,7 +1112,9 @@ TEST(IconBadgePreview, WritesPngsWhenRequested)
QString::fromLatin1(glyph.name));
for (std::size_t i = 0; i < std::size(SIZES); ++i) {
const int side = SIZES[i];
const QPixmap pixmap = badge ? ThemeIconBadge::renderBadge(svg, tone, QSize(side, side))
const QPixmap pixmap = badge ? ThemeIconBadge::renderBadge(svg, tone, QSize(side, side),
dark ? ThemeIconFill::Scheme::Dark
: ThemeIconFill::Scheme::Light)
: renderLine(svg, tone, QSize(side, side));
const qreal x = MARGIN + LABEL_WIDTH + static_cast<qreal>(i) * CELL_WIDTH;
// Align all sizes on a common baseline so the weight
@ -1127,6 +1140,7 @@ namespace
struct InkPalette
{
QColor text;
QColor buttonText;
QColor window;
QColor button;
QColor accent;
@ -1152,9 +1166,10 @@ InkPalette shippedPalette(const QString &theme, bool dark)
return {};
}
const QPalette palette = config.apply(QPalette());
return {palette.color(QPalette::Active, QPalette::WindowText), palette.color(QPalette::Active, QPalette::Window),
palette.color(QPalette::Active, QPalette::Button), palette.color(QPalette::Active, QPalette::Highlight),
config.appColors};
return {
palette.color(QPalette::Active, QPalette::WindowText), palette.color(QPalette::Active, QPalette::ButtonText),
palette.color(QPalette::Active, QPalette::Window), palette.color(QPalette::Active, QPalette::Button),
palette.color(QPalette::Active, QPalette::Highlight), config.appColors};
}
/**
@ -1699,3 +1714,189 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested)
ASSERT_TRUE(image.save(path)) << path.toStdString();
}
}
/**
* @brief Writes the authored emphasis sheets for every shipped palette theme.
*
* The ladder is only useful if it survives the treatment a call site chooses, so
* each sheet renders the same three authored levels as line glyphs, as sticker
* fills and as badges, at the two sizes the UI uses, on the two surfaces the
* contrast targets are measured against. Each family carries three glyphs, so
* the sheet also proves the level, not just one lucky shape.
*
* The authored tone is the top of every filled gradient, so the sheet prints that
* tone and the darker bottom the shape actually carries.
*/
TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested)
{
const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR");
if (outDir.isEmpty()) {
GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs";
}
ASSERT_TRUE(QDir().mkpath(outDir));
constexpr int MARGIN = 24;
constexpr int LABEL_WIDTH = 230;
constexpr int CELL_WIDTH = 166;
constexpr int ROW_HEIGHT = 58;
constexpr int PANEL_TITLE = 26;
constexpr int COLUMN_HEADER = 20;
constexpr int PANEL_PADDING = 12;
constexpr int GLYPH_SLOT = 32;
constexpr qreal DPR = 2.0;
constexpr int SIZES[] = {16, 24};
constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"};
constexpr const char *LEVEL_NAMES[] = {"Quiet", "Secondary", "Primary"};
struct Family
{
const char *name;
const char *glyphs[3];
AppColor::Role quiet;
AppColor::Role secondary;
AppColor::Role primary;
};
const QList<Family> families = {
{"Neutral", {"settings", "info", "player"}, AppColor::IconSoft, AppColor::Icon, AppColor::IconStrong},
{"Positive",
{"increment", "ready_start", "arrow_up"},
AppColor::PositiveSoft,
AppColor::Positive,
AppColor::PositiveStrong},
{"Negative",
{"decrement", "not_ready_start", "remove_row"},
AppColor::NegativeSoft,
AppColor::Negative,
AppColor::NegativeStrong},
};
const QStringList themes = {
QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"),
QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"),
};
const auto roleAt = [](const Family &family, int level) {
return level == 0 ? family.quiet : level == 1 ? family.secondary : family.primary;
};
const auto schemeFor = [](bool dark) { return dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; };
const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) {
QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1()));
QPixmap pixmap(size);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
renderer.render(&painter);
return pixmap;
};
const int panelWidth = LABEL_WIDTH + static_cast<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();
QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)),
QImage::Format_ARGB32_Premultiplied);
image.fill(palette.window);
QPainter painter(&image);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.scale(DPR, DPR);
painter.setPen(palette.text);
painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold));
painter.drawText(QPointF(MARGIN, MARGIN),
QStringLiteral("%1 — %2 scheme, authored emphasis across treatments")
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
struct Surface
{
const char *name;
QColor background;
QColor text;
};
const QList<Surface> surfaces = {
{"Window surface", palette.window, palette.text},
{"Button surface", palette.button, palette.buttonText},
};
qreal panelTop = MARGIN + 30;
for (const Surface &surface : surfaces) {
painter.setPen(Qt::NoPen);
painter.setBrush(surface.background);
painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8);
const qreal contentLeft = MARGIN + PANEL_PADDING;
painter.setPen(surface.text);
painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold));
painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name));
painter.setFont(QFont(QStringLiteral("Sans"), 8));
for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) {
const qreal x = contentLeft + LABEL_WIDTH + static_cast<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 AppColor::Role role = roleAt(family, level);
const QColor ink = palette.roles.value(role);
ASSERT_TRUE(ink.isValid())
<< theme.toStdString() << " " << family.name << " " << LEVEL_NAMES[level];
const QColor bottom = ThemeIconFill::bottomColor(ink);
painter.setPen(surface.text);
painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold));
painter.drawText(QPointF(contentLeft, y + 14),
QStringLiteral("%1 / %2").arg(QString::fromLatin1(family.name),
QString::fromLatin1(LEVEL_NAMES[level])));
painter.setFont(QFont(QStringLiteral("Mono"), 7));
painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb));
if (bottom != ink) {
painter.drawText(QPointF(contentLeft + 82, y + 28),
QStringLiteral("bottom ") + bottom.name(QColor::HexRgb));
}
for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) {
const qreal cellX = contentLeft + LABEL_WIDTH + static_cast<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, schemeFor(dark));
}
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-treatments-%1-%2.png")
.arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light")));
ASSERT_TRUE(image.save(path)) << path.toStdString();
}
}
}