[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 9449876a28
commit 05af82bd1e
9 changed files with 610 additions and 51 deletions

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