mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-07 10:33:24 -08:00
[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:
parent
76c8d8868a
commit
717f64a88b
9 changed files with 610 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
163
cockatrice/src/interface/theme_icon_badge.cpp
Normal file
163
cockatrice/src/interface/theme_icon_badge.cpp
Normal 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
|
||||
83
cockatrice/src/interface/theme_icon_badge.h
Normal file
83
cockatrice/src/interface/theme_icon_badge.h
Normal 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
|
||||
|
|
@ -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}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue