[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 fd97e00bfa
commit dc2ab19147
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