From 717f64a88b5f016ccb295615735e5aefb536f132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= <44814898+BruebachL@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:00:19 +0100 Subject: [PATCH] [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. --- cockatrice/CMakeLists.txt | 1 + .../player/player_list_widget.cpp | 4 +- cockatrice/src/interface/theme_icon.cpp | 98 ++++--- cockatrice/src/interface/theme_icon.h | 37 ++- cockatrice/src/interface/theme_icon_badge.cpp | 163 +++++++++++ cockatrice/src/interface/theme_icon_badge.h | 83 ++++++ oracle/CMakeLists.txt | 1 + tests/theme_migration/CMakeLists.txt | 1 + .../theme_icon_render_test.cpp | 273 ++++++++++++++++++ 9 files changed, 610 insertions(+), 51 deletions(-) create mode 100644 cockatrice/src/interface/theme_icon_badge.cpp create mode 100644 cockatrice/src/interface/theme_icon_badge.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index f34961b0b..bb6d3cf9c 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -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 diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index f73471bdc..bc27d85f9 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.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")); diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index 1739c2b43..796b50d45 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -1,6 +1,7 @@ #include "theme_icon.h" #include "libcockatrice/utility/svg_recolor.h" +#include "theme_icon_badge.h" #include "theme_manager.h" #include @@ -23,31 +24,8 @@ #include #include #include -#include #include -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(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(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 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); } diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h index 767aa0321..92cafb8e6 100644 --- a/cockatrice/src/interface/theme_icon.h +++ b/cockatrice/src/interface/theme_icon.h @@ -16,6 +16,32 @@ #include #include +/** + * @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 diff --git a/cockatrice/src/interface/theme_icon_badge.cpp b/cockatrice/src/interface/theme_icon_badge.cpp new file mode 100644 index 000000000..bb405c84d --- /dev/null +++ b/cockatrice/src/interface/theme_icon_badge.cpp @@ -0,0 +1,163 @@ +#include "theme_icon_badge.h" + +#include "libcockatrice/utility/svg_recolor.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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 diff --git a/cockatrice/src/interface/theme_icon_badge.h b/cockatrice/src/interface/theme_icon_badge.h new file mode 100644 index 000000000..f22baf697 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_badge.h @@ -0,0 +1,83 @@ +/** + * @file theme_icon_badge.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_BADGE_H +#define THEME_ICON_BADGE_H + +#include +#include +#include +#include + +/** + * @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 diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 4ca2acd07..1d1703f6f 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -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} ) diff --git a/tests/theme_migration/CMakeLists.txt b/tests/theme_migration/CMakeLists.txt index 725b765ac..61649e73b 100644 --- a/tests/theme_migration/CMakeLists.txt +++ b/tests/theme_migration/CMakeLists.txt @@ -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 diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index 3b9d5f818..493e6a3a1 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.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 +#include #include #include #include +#include #include #include +#include #include #include +#include #include #include #include +#include #include #include +#include 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(std::size(SIZES)) * CELL_WIDTH + MARGIN; + const int logicalHeight = MARGIN + 24 + 24 + static_cast(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(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(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(); + } +}