[Icons] Add a badge icon treatment

Restore the filled-disc look the ready/not-ready icons had before the
one-glyph cutover, without bringing back palette-locked art.

IconStyle::Badge composes the existing sentinel SVG at paint time: a
gradient disc, a dark ring, and the glyph re-weighted and inked for
contrast against the tone. Line stays the default and is unchanged.

Only the two state icons opt in. The add/remove icons keep the line
treatment because their old art was a filled glyph with an outline and
no disc, so badging them would invent a look the UI never had. Disabled
badges fall back to the line glyph so an unavailable control still reads
as unavailable.
This commit is contained in:
Lukas Brübach 2026-11-06 16:00:19 +01:00
parent 76c8d8868a
commit 717f64a88b
9 changed files with 610 additions and 51 deletions

View file

@ -152,6 +152,7 @@ set(cockatrice_SOURCES
src/interface/pixel_map_generator.cpp
src/interface/theme_config.cpp
src/interface/theme_icon.cpp
src/interface/theme_icon_badge.cpp
src/interface/theme_manager.cpp
src/interface/palette_editor/color_button.cpp
src/interface/palette_editor/palette_generator.cpp

View file

@ -68,8 +68,8 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor,
QWidget *parent)
: QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false)
{
readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive);
notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start"), AppColor::Negative);
readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge);
notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start"), AppColor::Negative, IconStyle::Badge);
concededIcon = loadThemedIcon(QStringLiteral("icons/conceded"));
playerIcon = loadThemedIcon(QStringLiteral("icons/player"));
judgeIcon = loadThemedIcon(QStringLiteral("icons/scales"));

View file

@ -1,6 +1,7 @@
#include "theme_icon.h"
#include "libcockatrice/utility/svg_recolor.h"
#include "theme_icon_badge.h"
#include "theme_manager.h"
#include <QApplication>
@ -23,31 +24,8 @@
#include <QStyle>
#include <QSvgRenderer>
#include <QtGlobal>
#include <cmath>
#include <utility>
namespace
{
/**
* @brief WCAG relative luminance, used to test a tint against a background.
*/
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);
}
} // namespace
IconTint::IconTint(QPalette::ColorRole role) : paletteRole(role)
{
}
@ -72,7 +50,7 @@ QColor IconTint::resolve(QIcon::Mode mode) const
// vanish into the selection.
constexpr double MIN_SELECTED_CONTRAST = 3.0;
const QColor highlight = qApp->palette().color(QPalette::Active, QPalette::Highlight);
return contrastRatio(color, highlight) >= MIN_SELECTED_CONTRAST
return ThemeIconBadge::contrastRatio(color, highlight) >= MIN_SELECTED_CONTRAST
? color
: qApp->palette().color(QPalette::Active, QPalette::HighlightedText);
}
@ -85,14 +63,6 @@ QColor IconTint::resolve(QIcon::Mode mode) const
namespace
{
/**
* @brief Color that replaces `currentColor`, following the icon mode.
*/
QByteArray sentinelColor(QIcon::Mode mode, const IconTint &tint)
{
return tint.resolve(mode).name(QColor::HexRgb).toLatin1();
}
/**
* @brief Reads an SVG through the `theme:` search path.
*/
@ -154,14 +124,19 @@ QString cacheKey(const QString &path, const QByteArray &variant, const QSize &si
* and the intrinsic size are cached until the theme changes: a repaint then
* does no file I/O at all. The render cache is keyed by resolved path and pixel
* size plus a variant: the palette color for sentinel SVGs, the icon mode for
* assets that carry their own colors. A palette change therefore produces new
* keys instead of reusing art built under the previous palette, and the
* non-sentinel path (raster overrides, plain SVGs) is cached like any other.
* assets that carry their own colors, and the tone plus glyph color for a badge.
* A palette change therefore produces new keys instead of reusing art built
* under the previous palette, and the non-sentinel path (raster overrides, plain
* SVGs) is cached like any other.
*
* The engine only resolves and caches. The badge shape itself is painted by
* ThemeIconBadge so the compositing stays out of the resolution path.
*/
class ThemeSvgIconEngine final : public QObject, public QIconEngine
{
public:
ThemeSvgIconEngine(QString _prefix, IconTint _tint) : prefix(std::move(_prefix)), tint(_tint)
ThemeSvgIconEngine(QString _prefix, IconTint _tint, IconStyle _style)
: prefix(std::move(_prefix)), tint(_tint), style(_style)
{
if (themeManager) {
// A theme switch changes the resolved asset (scheme-qualified
@ -175,7 +150,7 @@ public:
QIconEngine *clone() const override
{
return new ThemeSvgIconEngine(prefix, tint);
return new ThemeSvgIconEngine(prefix, tint, style);
}
void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State) override
@ -289,11 +264,27 @@ private:
{
ensureResolved();
// Same cache for both paths: sentinel art varies by palette color, art
// Badge is a shape treatment, so it only applies to sentinel art: an
// authored asset keeps its own drawing and follows the line/asset path.
// Disabled also drops back to the line glyph: a filled disc at full mass
// does not read as unavailable next to the thin line glyphs around it,
// and the tint already removes the semantic tone.
const bool badge = style == IconStyle::Badge && resolvedHasSentinel && mode != QIcon::Disabled;
// Same cache for all paths: sentinel art varies by palette color, art
// with its own colors varies by mode (Disabled is generated). A color
// variant is never a bare digit, so the two key spaces cannot collide.
const QByteArray variant =
resolvedHasSentinel ? sentinelColor(mode, tint) : QByteArray::number(static_cast<int>(mode));
// variant is never a bare digit, so the key spaces cannot collide. A
// badge also varies by glyph color, because the same tone can switch
// between a white and a near-black glyph at different lightnesses.
const QColor tone = resolvedHasSentinel ? tint.resolve(mode) : QColor();
QByteArray variant;
if (badge) {
variant = QByteArrayLiteral("B|") + tone.name(QColor::HexRgb).toLatin1() + '|' +
ThemeIconBadge::glyphColor(tone).name(QColor::HexRgb).toLatin1();
} else if (resolvedHasSentinel) {
variant = tone.name(QColor::HexRgb).toLatin1();
} else {
variant = QByteArray::number(static_cast<int>(mode));
}
const QString key = cacheKey(resolvedPath, variant, pixelSize, devicePixelRatio);
const auto cached = cache.constFind(key);
if (cached != cache.constEnd()) {
@ -301,7 +292,11 @@ private:
}
QPixmap pixmap;
if (resolvedHasSentinel) {
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);
} else if (resolvedHasSentinel) {
pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, variant), pixelSize, devicePixelRatio);
} else {
// Authored art carries its own colors: use Qt's normal loader so
@ -322,6 +317,7 @@ private:
QString prefix;
IconTint tint;
IconStyle style;
mutable QHash<QString, QPixmap> cache;
mutable QString resolvedPath;
mutable QString resolvedThemePath;
@ -334,12 +330,12 @@ private:
};
} // namespace
QIcon loadThemedIcon(QStringView prefix, IconTint tint)
QIcon loadThemedIcon(QStringView prefix, IconTint tint, IconStyle style)
{
return QIcon(new ThemeSvgIconEngine(prefix.toString(), tint));
return QIcon(new ThemeSvgIconEngine(prefix.toString(), tint, style));
}
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const QSize &size)
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const QSize &size, IconStyle style)
{
if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) {
return {};
@ -350,6 +346,14 @@ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const
return {};
}
const QByteArray color = tint.resolve(QIcon::Normal).name(QColor::HexRgb).toLatin1();
return renderSvg(resolveSvgColorSentinel(svg, color), size, 1.0);
const QColor tone = tint.resolve(QIcon::Normal);
if (style == IconStyle::Badge) {
QSize canvas = size;
if (canvas.isEmpty()) {
canvas = QSvgRenderer(svg).defaultSize();
}
return ThemeIconBadge::renderBadge(svg, tone, canvas);
}
return renderSvg(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()), size, 1.0);
}

View file

@ -16,6 +16,32 @@
#include <QString>
#include <QStringView>
/**
* @brief Selects how a `currentColor` glyph is painted.
*
* Style is a treatment, not a color: the tint still decides the tone, and the
* style decides how much shape carries that tone.
*
* Line is the default and the behavior every caller had before the axis
* existed: the recolored glyph alone, at the author's stroke weight. It stays
* the right choice for navigation, menu and toolbar glyphs, where a row of
* badges would outshout the content.
*
* Badge reproduces the pre-#7595 ready/not-ready art: a filled disc, a dark ring
* and a contrast glyph derived from the tone. It costs attention as a filled
* shape, so it is opt-in per call site and it is not a generic semantic
* treatment: the add/remove/delete icons were filled glyphs with an outline and
* no disc, so badging them would invent a look the UI never had. Authored art
* that carries its own colors is never badged; it falls back to the asset path.
* Disabled icons also fall back to the line glyph, because a filled disc at full
* mass does not read as unavailable.
*/
enum class IconStyle
{
Line, // the recolored glyph alone
Badge, // filled disc + dark ring + contrast glyph
};
/**
* @brief Selects the color that replaces the `currentColor` sentinel.
*
@ -64,9 +90,11 @@ private:
* extension (for example `icons/search`), matching themePixmap().
* @param tint Color that replaces the sentinel: a palette role or a semantic
* application color. Defaults to WindowText.
* @param style Treatment of the glyph: a line, or the badge shape that keeps
* the pre-migration filled-art reading. Defaults to Line.
* @return An icon that follows the application palette.
*/
QIcon loadThemedIcon(QStringView prefix, IconTint tint = {});
QIcon loadThemedIcon(QStringView prefix, IconTint tint = {}, IconStyle style = IconStyle::Line);
/**
* @brief Renders a resolved theme SVG when it carries the `currentColor` sentinel.
@ -79,9 +107,14 @@ QIcon loadThemedIcon(QStringView prefix, IconTint tint = {});
* @param tint Color that replaces the sentinel: a palette role or a semantic
* application color. Defaults to WindowText.
* @param size Pixel size to render at; an empty size uses the SVG's intrinsic size.
* @param style Treatment of the glyph: a line, or a badge. A badge on a
* non-sentinel asset stays null, like the line path.
* @return The recolored pixmap, or a null pixmap when the asset is not a
* sentinel-carrying SVG.
*/
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint = {}, const QSize &size = {});
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath,
IconTint tint = {},
const QSize &size = {},
IconStyle style = IconStyle::Line);
#endif // THEME_ICON_H

View file

@ -0,0 +1,163 @@
#include "theme_icon_badge.h"
#include "libcockatrice/utility/svg_recolor.h"
#include <QLinearGradient>
#include <QPainter>
#include <QPen>
#include <QPointF>
#include <QRectF>
#include <QSvgRenderer>
#include <QtGlobal>
#include <cmath>
namespace
{
// The pre-#7595 badge art filled almost the whole canvas: a 24px icon carried a
// 23px disc with a hairline black outline. Keep the disc large and the ring
// thin so the glyph still has room at 16px.
constexpr double RING_WIDTH_RATIO = 0.04;
// The sentinel paths sit inside a padded 24-unit viewBox (the check spans 4..20),
// so the glyph box has to be nearly the whole disc for the engraved mark to
// match the old art's weight. At half the disc it read as a smudge at 15px.
constexpr double GLYPH_DIAMETER_RATIO = 0.9;
// The sentinel set is drawn at 2 units in a 24-unit viewBox, which turns into a
// hairline once the glyph is scaled into a 15px disc. The old badge carried a
// band near a tenth of the disc diameter, so the badge decides the glyph weight
// instead of inheriting the authored one.
constexpr qreal GLYPH_STROKE_WIDTH = 3.0;
constexpr int GRADIENT_TOP_LIGHTER = 112;
constexpr int GRADIENT_BOTTOM_DARKER = 104;
constexpr int RING_DARKER = 300;
constexpr int GLYPH_DARKER = 400;
/**
* @brief Gives the engraved glyph the weight the badge needs.
*
* Inheritance cannot do this: the authored `stroke-width` is tuned for a 24px
* line next to text, not for a mark sitting inside a 13px disc. Overriding every
* stroke in the glyph keeps the check, cross and arrow readable at the sizes the
* UI uses.
*
* Stays in bytes like resolveSvgColorSentinel(): the SVG is never decoded, so a
* theme asset that is not valid UTF-8 survives the rewrite.
*/
QByteArray withBadgeStrokeWeight(const QByteArray &svg)
{
constexpr QByteArrayView ATTRIBUTE = "stroke-width";
constexpr QByteArrayView EQUALS = "=";
constexpr QByteArrayView QUOTE = "\"";
const QByteArray value = QByteArray::number(GLYPH_STROKE_WIDTH, 'f', 1);
QByteArray resolved;
resolved.reserve(svg.size());
qsizetype cursor = 0;
while (true) {
const qsizetype start = svg.indexOf(ATTRIBUTE, cursor);
if (start < 0) {
resolved.append(svg.sliced(cursor));
return resolved;
}
const qsizetype equals = svg.indexOf(EQUALS, start + ATTRIBUTE.size());
const qsizetype quote = equals < 0 ? -1 : svg.indexOf(QUOTE, equals);
const qsizetype close = quote < 0 ? -1 : svg.indexOf(QUOTE, quote + 1);
if (close < 0) {
// Not an attribute we understand: leave the rest untouched.
resolved.append(svg.sliced(cursor));
return resolved;
}
resolved.append(svg.sliced(cursor, quote + 1 - cursor));
resolved.append(value);
cursor = close;
}
}
} // namespace
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)
{
// Pick the more legible of the two inks rather than a literal: white keeps
// the old light-scheme look, and the tone darkened to an outline-dark ink
// wins on the light tones a dark scheme uses, where white drops under 3:1.
// Deriving it from the tone keeps themes in control and leaves no hardcoded
// hex in the paint path.
const QColor darkInk = tone.darker(GLYPH_DARKER);
return contrastRatio(tone, QColor(Qt::white)) >= contrastRatio(tone, darkInk) ? QColor(Qt::white) : darkInk;
}
QSize glyphSize(const QSize &pixelSize)
{
if (pixelSize.isEmpty()) {
return {};
}
const qreal side = qMin(pixelSize.width(), pixelSize.height());
const qreal ringWidth = qMax(1.0, side * RING_WIDTH_RATIO);
const qreal discDiameter = side - 2.0 * ringWidth;
const int glyphSide = qMax(1, qRound(discDiameter * GLYPH_DIAMETER_RATIO));
return QSize(glyphSide, glyphSide);
}
QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize)
{
if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) {
return {};
}
QPixmap pixmap(pixelSize);
pixmap.fill(Qt::transparent);
const qreal side = qMin(pixelSize.width(), pixelSize.height());
const qreal ringWidth = qMax(1.0, side * RING_WIDTH_RATIO);
const qreal discDiameter = side - 2.0 * ringWidth;
QRectF discRect(0, 0, discDiameter, discDiameter);
discRect.moveCenter(QRectF(QPointF(0, 0), QSizeF(pixelSize)).center());
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
QLinearGradient gradient(discRect.topLeft(), discRect.bottomLeft());
gradient.setColorAt(0.0, tone.lighter(GRADIENT_TOP_LIGHTER));
gradient.setColorAt(1.0, tone.darker(GRADIENT_BOTTOM_DARKER));
painter.setPen(Qt::NoPen);
painter.setBrush(gradient);
painter.drawEllipse(discRect);
// 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(tone.darker(RING_DARKER), ringWidth));
const qreal ringRadius = (side - ringWidth) / 2.0;
painter.drawEllipse(discRect.center(), ringRadius, ringRadius);
const QByteArray glyphSvg =
withBadgeStrokeWeight(resolveSvgColorSentinel(svg, glyphColor(tone).name(QColor::HexRgb).toLatin1()));
QSvgRenderer renderer(glyphSvg);
if (renderer.isValid()) {
const QSize size = glyphSize(pixelSize);
const QRectF glyphRect(
QPointF(discRect.center().x() - size.width() / 2.0, discRect.center().y() - size.height() / 2.0),
QSizeF(size));
renderer.render(&painter, glyphRect);
}
return pixmap;
}
} // namespace ThemeIconBadge

View file

@ -0,0 +1,83 @@
/**
* @file theme_icon_badge.h
* @ingroup Interface
*/
#ifndef THEME_ICON_BADGE_H
#define THEME_ICON_BADGE_H
#include <QByteArray>
#include <QColor>
#include <QPixmap>
#include <QSize>
/**
* @brief Paints the badge treatment of a `currentColor` glyph.
*
* A tint alone can say "positive" or "negative", but a 2px line at 16px reads as
* a different thing from the filled badge art the UI used before the one-glyph
* cutover. The badge treatment keeps that shape: a filled disc, a dark ring, and
* a contrast glyph, all derived from the one tone the tint resolves to. So the
* theme still owns the color, and callers only choose between a line and a
* badge.
*
* The trade-off is fidelity against contrast. A badge is a small picture: it
* survives a busy toolbar and a tinted row better than a hairline, and it can
* carry a light or dark glyph independent of the surrounding text. In exchange
* it costs more pixels of attention per control, and its glyph color is decided
* from the disc it sits on rather than from the window, so it is deliberately
* not the palette's text color.
*
* Everything here works in device pixels. The icon engine renders at the
* device-pixel size it already computed and sets the pixmap's device pixel ratio
* afterwards, so badge geometry must not assume logical pixels.
*/
namespace ThemeIconBadge
{
/**
* @brief WCAG relative luminance, used to test a color against a background.
*
* Shared by the badge glyph choice and by IconTint::resolve()'s selected-row
* contrast test, so both use the same definition of "legible".
*/
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.
*
* The more legible of white and the tone darkened to an outline-dark ink. White
* keeps the old light-scheme look; on a dark scheme the semantic tone is a light
* green or red, where white sits near 1.7:1 and disappears, so the dark ink
* wins instead. Both candidates derive from the theme, so no literal is needed.
*/
QColor glyphColor(const QColor &tone);
/** @brief Size of the engraved glyph for a badge of @p pixelSize, in device pixels. */
QSize glyphSize(const QSize &pixelSize);
/**
* @brief Renders a sentinel-carrying SVG as a badge.
*
* The disc fills the square canvas minus the ring, the ring darkens the tone,
* and the sentinel is resolved to the contrast glyph. The SVG is scaled to
* nearly the whole disc, because the sentinel paths are padded inside their
* viewBox and a smaller box loses the stroke at 16px.
*
* This treatment reproduces the pre-#7595 ready/not-ready art, which was a
* filled disc. It is not a generic replacement for every semantic glyph: the
* old add/remove icons were filled glyphs with an outline and no disc, so
* badging them would invent a look the UI never had.
*
* @param svg Sentinel-carrying SVG source; `currentColor` is resolved internally.
* @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.
* @return The composited badge, or a null pixmap for empty or invalid input.
*/
QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize);
} // namespace ThemeIconBadge
#endif // THEME_ICON_BADGE_H

View file

@ -32,6 +32,7 @@ set(oracle_SOURCES
../cockatrice/src/interface/pixel_map_generator.cpp
../cockatrice/src/interface/theme_config.cpp
../cockatrice/src/interface/theme_icon.cpp
../cockatrice/src/interface/theme_icon_badge.cpp
../cockatrice/src/interface/theme_manager.cpp
${VERSION_STRING_CPP}
)

View file

@ -6,6 +6,7 @@ cockatrice_add_test(
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/pixel_map_generator.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_badge.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp

View file

@ -1,19 +1,27 @@
#include "interface/theme_icon.h"
#include "interface/theme_icon_badge.h"
#include "interface/theme_manager.h"
#include "libcockatrice/utility/svg_recolor.h"
#include <QApplication>
#include <QByteArray>
#include <QColor>
#include <QDir>
#include <QFile>
#include <QFont>
#include <QGuiApplication>
#include <QImage>
#include <QPainter>
#include <QPalette>
#include <QPixmap>
#include <QPointF>
#include <QSize>
#include <QStringList>
#include <QStringLiteral>
#include <QSvgRenderer>
#include <QTemporaryDir>
#include <gtest/gtest.h>
#include <iterator>
namespace
{
@ -270,3 +278,268 @@ TEST_F(ThemeIconRenderTest, SelectedAppColorKeepsAContrastingTint)
EXPECT_EQ(IconTint(AppColor::Positive).resolve(QIcon::Selected), themeManager->appColor(AppColor::Positive));
}
/** @brief A badge is a disc with a gradient fill, a dark ring and a contrast glyph. */
TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing)
{
const QColor tone = themeManager->appColor(AppColor::Positive);
ASSERT_TRUE(tone.isValid());
const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge);
const QPixmap pixmap = icon.pixmap(QSize(64, 64));
ASSERT_FALSE(pixmap.isNull());
EXPECT_EQ(pixmap.size(), QSize(64, 64));
const QImage image = pixmap.toImage();
// The disc fills the circle; the square corners stay transparent.
EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0);
EXPECT_GT(image.pixelColor(32, 32).alpha(), 200);
// The ring is the 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.
const int fillX = 10;
const QColor top = image.pixelColor(fillX, 16);
const QColor bottom = image.pixelColor(fillX, 48);
ASSERT_GT(top.alpha(), 200);
ASSERT_GT(bottom.alpha(), 200);
EXPECT_GT(top.lightness(), bottom.lightness());
// The glyph uses the contrast color chosen for this tone.
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0);
}
/** @brief The badge is rendered at the device-pixel size, not upscaled later. */
TEST_F(ThemeIconRenderTest, BadgeHonoursDevicePixelRatio)
{
const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge);
const QPixmap hidpi = icon.pixmap(QSize(32, 32), 2.0);
ASSERT_FALSE(hidpi.isNull());
EXPECT_EQ(hidpi.size(), QSize(64, 64));
EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0);
EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(32, 32));
}
/**
* @brief The player list is the one place the badge has to work, at 15px.
*
* A 20x15 icon box yields a 15px canvas. The art this replaces kept its check
* legible there, so the badge must still paint solid glyph ink rather than an
* antialiased smudge.
*/
TEST_F(ThemeIconRenderTest, BadgeStaysLegibleAtPlayerListSize)
{
const QColor tone = themeManager->appColor(AppColor::Positive);
const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge);
const QPixmap pixmap = icon.pixmap(QSize(20, 15));
ASSERT_FALSE(pixmap.isNull());
EXPECT_EQ(pixmap.deviceIndependentSize(), QSizeF(15, 15));
// The disc survives: the corners stay transparent and the center is solid.
const QImage image = pixmap.toImage();
EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0);
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);
}
/** @brief The pixmap API's badge path agrees with the engine's. */
TEST_F(ThemeIconRenderTest, SentinelSvgBadgeMatchesTheEngine)
{
const QColor tone = themeManager->appColor(AppColor::Positive);
const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/ready_start.svg"), AppColor::Positive,
QSize(48, 48), IconStyle::Badge);
ASSERT_FALSE(pixmap.isNull());
EXPECT_EQ(pixmap.size(), QSize(48, 48));
EXPECT_GT(countOpaquePixels(pixmap, tone.darker(300)), 0);
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0);
}
/** @brief Badge never paints authored art: the asset keeps its own colors. */
TEST_F(ThemeIconRenderTest, BadgeOnAuthoredArtUsesTheAssetPath)
{
QTemporaryDir themeDir;
ASSERT_TRUE(themeDir.isValid());
QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg")));
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
file.write(svgWith("#336699"));
file.close();
QDir::addSearchPath(QStringLiteral("theme"), themeDir.path());
const QColor tone = themeManager->appColor(AppColor::Positive);
const QIcon icon = loadThemedIcon(QStringLiteral("plain"), AppColor::Positive, IconStyle::Badge);
const QPixmap pixmap = icon.pixmap(QSize(32, 32));
ASSERT_FALSE(pixmap.isNull());
// 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, tone.darker(300)), 0);
}
/** @brief Disabled keeps the shape language but drops back to the line glyph. */
TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph)
{
QPalette palette = qApp->palette();
const QColor disabledText(QStringLiteral("#fedcba"));
palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText);
qApp->setPalette(palette);
const QColor tone = themeManager->appColor(AppColor::Positive);
const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge);
const QPixmap pixmap = icon.pixmap(QSize(64, 64), QIcon::Disabled);
ASSERT_FALSE(pixmap.isNull());
// 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, 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);
}
/** @brief Line stays the default: no disc, no ring, just the recolored glyph. */
TEST_F(ThemeIconRenderTest, LineStyleStillRendersGlyphAlone)
{
const QColor tone = themeManager->appColor(AppColor::Positive);
const QPixmap pixmap =
loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive).pixmap(QSize(64, 64));
ASSERT_FALSE(pixmap.isNull());
EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0);
EXPECT_GT(countOpaquePixels(pixmap, tone), 0);
}
/**
* @brief Writes the PR preview sheets when asked; skipped otherwise.
*
* Two panels per scheme: ready/not-ready as line and badge at the sizes the UI
* actually uses (15px in the player list, 16px in toolbars, 24 and 48 for
* inspection), and the add/remove/delete glyphs, which stay line because the
* old art was a filled glyph and not a disc. It lives in the test binary so the
* colors come from the same place the engine uses.
*/
TEST(IconBadgePreview, 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));
struct Glyph
{
const char *name;
AppColor::Role role;
};
constexpr Glyph badged[] = {
{"ready_start", AppColor::Positive},
{"not_ready_start", AppColor::Negative},
};
constexpr Glyph unchanged[] = {
{"increment", AppColor::Positive},
{"decrement", AppColor::Negative},
{"remove_row", AppColor::Negative},
{"delete", AppColor::Negative},
};
constexpr int SIZES[] = {15, 16, 24, 48};
constexpr int LINE_GLYPH_SIZE = 32;
const auto readSvg = [](const char *name) {
QFile file(
QDir(resourcesDir())
.absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg")));
return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
};
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 = 24;
constexpr int LABEL_WIDTH = 90;
constexpr int CELL_WIDTH = 120;
constexpr int ROW_HEIGHT = 58;
constexpr int GLYPH_BLOCK_HEIGHT = 22 + 2 * ROW_HEIGHT;
constexpr qreal DPR = 2.0;
const int logicalWidth = MARGIN + LABEL_WIDTH + static_cast<int>(std::size(SIZES)) * CELL_WIDTH + MARGIN;
const int logicalHeight = MARGIN + 24 + 24 + static_cast<int>(std::size(badged)) * GLYPH_BLOCK_HEIGHT + 24 + 24 +
LINE_GLYPH_SIZE + 30 + MARGIN;
const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR));
for (const bool dark : {false, true}) {
const QColor window(dark ? QStringLiteral("#1e1e1e") : QStringLiteral("#ffffff"));
const QColor text(dark ? QStringLiteral("#e8e8e8") : QStringLiteral("#1e1e1e"));
QImage image(imageSize, QImage::Format_ARGB32_Premultiplied);
image.fill(window);
QPainter painter(&image);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.scale(DPR, DPR);
painter.setPen(text);
painter.setFont(QFont(QStringLiteral("Sans"), 9));
qreal y = MARGIN + 4;
painter.drawText(QPointF(MARGIN, y), dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme"));
y += 24;
painter.drawText(QPointF(MARGIN, y), QStringLiteral("Ready / not ready (line above, badge below)"));
for (const Glyph &glyph : badged) {
y += GLYPH_BLOCK_HEIGHT;
const QByteArray svg = readSvg(glyph.name);
ASSERT_FALSE(svg.isEmpty()) << glyph.name;
const QColor tone = defaultAppColor(glyph.role, dark);
for (int row = 0; row < 2; ++row) {
const bool badge = row == 1;
const qreal rowTop = y - GLYPH_BLOCK_HEIGHT + 22 + row * ROW_HEIGHT;
painter.drawText(QPointF(MARGIN, rowTop + 16),
(badge ? QStringLiteral("badge ") : QStringLiteral("line ")) +
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))
: 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
// difference between 15px and 48px is visible.
painter.drawPixmap(QPointF(x, rowTop + 36 - side), pixmap);
if (row == 1) {
painter.drawText(QPointF(x, rowTop + 54), QStringLiteral("%1px").arg(side));
}
}
}
}
y += 24;
painter.drawText(QPointF(MARGIN, y),
QStringLiteral("Add / remove / delete (unchanged line glyphs, %1px)").arg(LINE_GLYPH_SIZE));
for (std::size_t i = 0; i < std::size(unchanged); ++i) {
const QByteArray svg = readSvg(unchanged[i].name);
ASSERT_FALSE(svg.isEmpty()) << unchanged[i].name;
const QColor tone = defaultAppColor(unchanged[i].role, dark);
const qreal x = MARGIN + LABEL_WIDTH + static_cast<qreal>(i) * CELL_WIDTH;
painter.drawPixmap(QPointF(x, y + 8), renderLine(svg, tone, QSize(LINE_GLYPH_SIZE, LINE_GLYPH_SIZE)));
painter.drawText(QPointF(x, y + 8 + LINE_GLYPH_SIZE + 12), QString::fromLatin1(unchanged[i].name));
}
painter.end();
const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("badge-preview-dark.png")
: QStringLiteral("badge-preview-light.png"));
ASSERT_TRUE(image.save(path)) << path.toStdString();
}
}