[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.
This commit is contained in:
Lukas Brübach 2026-11-06 23:13:40 +01:00
parent 86c8790b48
commit 9ae7c6678c
5 changed files with 341 additions and 14 deletions

View file

@ -320,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;
}
@ -347,13 +364,11 @@ 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 ThemeIconInk::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 &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;
@ -362,8 +377,10 @@ QColor IconTint::resolve(QIcon::Mode mode) const
// 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));
// 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;
}

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.
@ -74,9 +102,18 @@ public:
* 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;
IconEmphasis getEmphasis() const
{
return emphasis;
}
private:
enum class Source
{
@ -87,6 +124,7 @@ private:
Source source = Source::Palette;
QPalette::ColorRole paletteRole = QPalette::WindowText;
AppColor::Role appRole = AppColor::AccentStrong;
IconEmphasis emphasis = IconEmphasis::Secondary;
};
/**

View file

@ -14,6 +14,18 @@ namespace
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;
@ -87,4 +99,32 @@ 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

@ -6,6 +6,8 @@
#ifndef THEME_ICON_INK_H
#define THEME_ICON_INK_H
#include "theme_icon.h"
#include <QColor>
/**
@ -93,6 +95,33 @@ QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent,
* 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

@ -1348,3 +1348,206 @@ TEST(IconInkPreview, WritesPngsWhenRequested)
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 sheet when asked; skipped otherwise.
*
* One strip per scheme: a mock toolbar with body text, a quiet labelling glyph,
* secondary actions, the primary action, and the primary action on an
* accent-filled button. The point is the hierarchy at a glance, not the glyph.
*/
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 ROW_HEIGHT = 44;
constexpr int ICON_SIZE = 20;
constexpr qreal DPR = 2.0;
constexpr int WIDTH = 760;
struct Sample
{
const char *glyph;
const char *label;
IconTint tint;
};
for (const bool dark : {false, true}) {
const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark);
ASSERT_TRUE(palette.text.isValid());
ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), dark));
const QList<Sample> samples = {
{nullptr, "body text for comparison", {}},
{"search", "quiet hint", IconTint::quiet()},
{"settings", "secondary", IconTint()},
{"arrow_right", "primary", IconTint::primary()},
{"increment", "primary, positive", IconTint(AppColor::Positive, IconEmphasis::Primary)},
};
const int logicalHeight = MARGIN + 28 + static_cast<int>(samples.size()) * ROW_HEIGHT + ROW_HEIGHT + MARGIN;
QImage image(QSize(qRound(WIDTH * 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);
qreal y = MARGIN;
painter.setPen(palette.text);
painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold));
painter.drawText(QPointF(MARGIN, y), (dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")) +
QStringLiteral(" — emphasis ladder"));
y += 28;
for (const Sample &sample : samples) {
if (sample.glyph != nullptr) {
const QByteArray svg = readIconSvg(sample.glyph);
ASSERT_FALSE(svg.isEmpty()) << sample.glyph;
const QColor ink = sample.tint.resolve(QIcon::Normal);
QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1()));
QPixmap glyph(QSize(ICON_SIZE, ICON_SIZE));
glyph.fill(Qt::transparent);
QPainter glyphPainter(&glyph);
glyphPainter.setRenderHint(QPainter::Antialiasing, true);
renderer.render(&glyphPainter);
glyphPainter.end();
painter.drawPixmap(QPointF(MARGIN, y - ICON_SIZE + 4), glyph);
}
painter.setPen(palette.text);
painter.setFont(QFont(QStringLiteral("Sans"), 10));
painter.drawText(QPointF(MARGIN + ICON_SIZE + 16, y), QString::fromLatin1(sample.label));
y += ROW_HEIGHT;
}
// The primary action on an accent-filled button: the container carries
// the emphasis, and the glyph has to switch to the on-accent ink.
const QRectF button(MARGIN, y - 28, 220, 34);
painter.setBrush(palette.accent);
painter.setPen(Qt::NoPen);
painter.drawRoundedRect(button, 6, 6);
painter.setPen(palette.text);
painter.setFont(QFont(QStringLiteral("Sans"), 10));
painter.drawText(QPointF(MARGIN + 12, y - 6), QStringLiteral("primary button: accent fill + on-accent glyph"));
painter.end();
const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("emphasis-preview-dark.png")
: QStringLiteral("emphasis-preview-light.png"));
ASSERT_TRUE(image.save(path)) << path.toStdString();
}
}