diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bb6d3cf9c..a073e8a07 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -153,6 +153,8 @@ set(cockatrice_SOURCES src/interface/theme_config.cpp src/interface/theme_icon.cpp src/interface/theme_icon_badge.cpp + src/interface/theme_icon_fill.cpp + src/interface/theme_icon_sticker.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/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 303f3e3f6..890da0f62 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -30,7 +30,8 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) typeCombo->addItem(CardFilter::typeName(static_cast(i)), QVariant(i)); } - QPushButton *ok = new QPushButton(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive), QString()); + QPushButton *ok = new QPushButton( + loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker), QString()); ok->setObjectName("ok"); ok->setMaximumSize(20, 20); diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index 796b50d45..d85e84f87 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -2,6 +2,8 @@ #include "libcockatrice/utility/svg_recolor.h" #include "theme_icon_badge.h" +#include "theme_icon_fill.h" +#include "theme_icon_sticker.h" #include "theme_manager.h" #include @@ -41,7 +43,11 @@ QColor IconTint::resolve(QIcon::Mode mode) const if (mode == QIcon::Disabled) { return qApp->palette().color(QPalette::Disabled, QPalette::WindowText); } - const QColor color = themeManager->appColor(appRole); + // The three treatments paint the same tone: a line glyph, a badge disc + // and a sticker fill must not disagree on how saturated the role is. + // The fill treatments apply this floor themselves, so this only changes + // the line path, and only for the desaturated dark-scheme tones. + const QColor color = ThemeIconFill::saturated(themeManager->appColor(appRole)); if (mode != QIcon::Selected) { return color; } @@ -264,12 +270,14 @@ private: { ensureResolved(); - // 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; + // Badge and Sticker are shape treatments, so they only apply 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 or + // sticker 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 treated = style != IconStyle::Line && resolvedHasSentinel && mode != QIcon::Disabled; + const bool badge = treated && style == IconStyle::Badge; + const bool sticker = treated && style == IconStyle::Sticker; // 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 key spaces cannot collide. A @@ -280,6 +288,8 @@ private: if (badge) { variant = QByteArrayLiteral("B|") + tone.name(QColor::HexRgb).toLatin1() + '|' + ThemeIconBadge::glyphColor(tone).name(QColor::HexRgb).toLatin1(); + } else if (sticker) { + variant = QByteArrayLiteral("S|") + tone.name(QColor::HexRgb).toLatin1(); } else if (resolvedHasSentinel) { variant = tone.name(QColor::HexRgb).toLatin1(); } else { @@ -296,6 +306,8 @@ private: // 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 (sticker) { + pixmap = ThemeIconSticker::renderSticker(resolvedSvg, tone, pixelSize); } else if (resolvedHasSentinel) { pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, variant), pixelSize, devicePixelRatio); } else { @@ -347,12 +359,13 @@ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const } const QColor tone = tint.resolve(QIcon::Normal); - if (style == IconStyle::Badge) { + if (style == IconStyle::Badge || style == IconStyle::Sticker) { QSize canvas = size; if (canvas.isEmpty()) { canvas = QSvgRenderer(svg).defaultSize(); } - return ThemeIconBadge::renderBadge(svg, tone, canvas); + return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas) + : ThemeIconSticker::renderSticker(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 92cafb8e6..aad38c7cb 100644 --- a/cockatrice/src/interface/theme_icon.h +++ b/cockatrice/src/interface/theme_icon.h @@ -35,11 +35,16 @@ * 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. + * + * Sticker reproduces the other half of the pre-#7595 set: the filled add, remove + * and delete glyphs, painted with a tone gradient and a dark outline and no + * disc. It shares the disabled fallback for the same reason. */ enum class IconStyle { - Line, // the recolored glyph alone - Badge, // filled disc + dark ring + contrast glyph + Line, // the recolored glyph alone + Badge, // filled disc + dark ring + contrast glyph + Sticker, // filled glyph + dark outline, no disc }; /** @@ -62,6 +67,9 @@ public: * Disabled application colors resolve to the disabled foreground so an * unavailable control does not keep signalling its semantic color. Selected * palette colors keep the existing WindowText -> HighlightedText swap. + * Application colors pass through the filled treatments' saturation floor, + * so a line glyph, a badge disc and a sticker fill carry the same tone + * instead of a pale line beside a saturated shape. */ QColor resolve(QIcon::Mode mode) const; @@ -90,8 +98,8 @@ 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. + * @param style Treatment of the glyph: a line, or one of the filled treatments + * that keep the pre-migration art reading. Defaults to Line. * @return An icon that follows the application palette. */ QIcon loadThemedIcon(QStringView prefix, IconTint tint = {}, IconStyle style = IconStyle::Line); @@ -107,8 +115,9 @@ QIcon loadThemedIcon(QStringView prefix, IconTint tint = {}, IconStyle style = I * @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. + * @param style Treatment of the glyph: a line, or one of the filled treatments. + * A treatment 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. */ diff --git a/cockatrice/src/interface/theme_icon_badge.cpp b/cockatrice/src/interface/theme_icon_badge.cpp index bb405c84d..e9ec057f7 100644 --- a/cockatrice/src/interface/theme_icon_badge.cpp +++ b/cockatrice/src/interface/theme_icon_badge.cpp @@ -1,6 +1,7 @@ #include "theme_icon_badge.h" #include "libcockatrice/utility/svg_recolor.h" +#include "theme_icon_fill.h" #include #include @@ -26,50 +27,21 @@ constexpr double GLYPH_DIAMETER_RATIO = 0.9; // 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; +} // namespace -/** - * @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) +namespace { - 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()); +struct DiscFill +{ + QColor top; + QColor bottom; +}; - 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; - } +DiscFill discFill(const QColor &tone) +{ + return {ThemeIconFill::topColor(tone), ThemeIconFill::bottomColor(tone)}; } } // namespace @@ -93,13 +65,14 @@ double contrastRatio(const QColor &first, const QColor &second) 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; + // The badge carries the ink the pre-sticker badge did: white on the light + // scheme, and the tone darkened to an outline-dark ink on the dark scheme. + // The shared saturated disc is light enough there that white sits near + // 2.7:1 at the top, so the dark ink is the deliberate choice rather than a + // contrast measurement that a palette tweak could flip. Deriving it from + // the tone keeps themes in control. + return ThemeIconFill::isDarkSchemeTone(tone) ? ThemeIconFill::saturated(tone).darker(GLYPH_DARKER) + : QColor(Qt::white); } QSize glyphSize(const QSize &pixelSize) @@ -134,8 +107,9 @@ QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixe 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)); + const DiscFill fill = discFill(tone); + gradient.setColorAt(0.0, fill.top); + gradient.setColorAt(1.0, fill.bottom); painter.setPen(Qt::NoPen); painter.setBrush(gradient); painter.drawEllipse(discRect); @@ -143,12 +117,12 @@ QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixe // 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)); + painter.setPen(QPen(ThemeIconFill::saturated(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())); + const QByteArray glyphSvg = resolveSvgStrokeWidth( + resolveSvgColorSentinel(svg, glyphColor(tone).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH); QSvgRenderer renderer(glyphSvg); if (renderer.isValid()) { const QSize size = glyphSize(pixelSize); diff --git a/cockatrice/src/interface/theme_icon_badge.h b/cockatrice/src/interface/theme_icon_badge.h index f22baf697..5ff839034 100644 --- a/cockatrice/src/interface/theme_icon_badge.h +++ b/cockatrice/src/interface/theme_icon_badge.h @@ -24,9 +24,9 @@ * 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. + * it costs more pixels of attention per control, and its glyph color follows the + * scheme it is drawn for rather than the window's text color, so it is + * deliberately not the palette's foreground. * * 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 @@ -37,8 +37,7 @@ 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". + * Used by IconTint::resolve()'s selected-row contrast test. */ double relativeLuminance(const QColor &color); @@ -48,10 +47,11 @@ 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. + * White on the light scheme, the saturated tone darkened to an outline-dark ink + * on the dark scheme. The dark-scheme tones fill the disc light enough that + * white would sit near 2.7:1 at its top, so that scheme keeps the darkened ink + * the badge had before the filled treatments shared a fill. The dark ink is + * derived from the tone, so the theme keeps control of both ends. */ QColor glyphColor(const QColor &tone); @@ -61,10 +61,11 @@ 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. + * The disc fills the square canvas minus the ring with the same gradient the + * sticker fill uses, 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 diff --git a/cockatrice/src/interface/theme_icon_fill.cpp b/cockatrice/src/interface/theme_icon_fill.cpp new file mode 100644 index 000000000..7615fb4fa --- /dev/null +++ b/cockatrice/src/interface/theme_icon_fill.cpp @@ -0,0 +1,61 @@ +#include "theme_icon_fill.h" + +#include + +namespace +{ +// Semantic tones are tuned for text. The dark-scheme ones are deliberately +// desaturated, which reads washed out when the same color fills a whole shape, +// so the fill gives its tone a saturation floor. +constexpr int MIN_SATURATION = 170; +// A tone this light belongs to the dark scheme; filling a glyph with it at full +// value is what looks pale, so its gradient is pulled down instead of up. +constexpr int LIGHT_TONE_VALUE = 200; +constexpr int MIN_VALUE = 200; +constexpr int GRADIENT_BOTTOM_DARKER = 140; +constexpr int DARK_SCHEME_TOP_DARKER = 120; +constexpr int DARK_SCHEME_BOTTOM_DARKER = 170; +} // namespace + +namespace +{ +/** @brief Raises a tone's saturation, and optionally its value, for a filled shape. */ +QColor boost(const QColor &color, int minValue) +{ + if (color.hue() < 0 || (color.hsvSaturation() >= MIN_SATURATION && color.value() >= minValue)) { + return color; + } + QColor result = color; + result.setHsv(color.hue(), qMax(color.hsvSaturation(), MIN_SATURATION), qMax(color.value(), minValue)); + // Return RGB so callers comparing or caching the tone do not see a spec + // change for an unchanged color. + return result.toRgb(); +} +} // namespace + +namespace ThemeIconFill +{ +QColor saturated(const QColor &tone) +{ + return boost(tone, 0); +} + +bool isDarkSchemeTone(const QColor &tone) +{ + return tone.value() >= LIGHT_TONE_VALUE; +} + +QColor topColor(const QColor &tone) +{ + if (isDarkSchemeTone(tone)) { + return boost(tone, 0).darker(DARK_SCHEME_TOP_DARKER); + } + return boost(tone, MIN_VALUE); +} + +QColor bottomColor(const QColor &tone) +{ + const int darker = isDarkSchemeTone(tone) ? DARK_SCHEME_BOTTOM_DARKER : GRADIENT_BOTTOM_DARKER; + return boost(tone, 0).darker(darker); +} +} // namespace ThemeIconFill diff --git a/cockatrice/src/interface/theme_icon_fill.h b/cockatrice/src/interface/theme_icon_fill.h new file mode 100644 index 000000000..16e2f09f2 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_fill.h @@ -0,0 +1,64 @@ +/** + * @file theme_icon_fill.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_FILL_H +#define THEME_ICON_FILL_H + +#include + +/** + * @brief Picks the two colors of a filled treatment's vertical gradient. + * + * The pre-#7595 icons were filled with a lighter top over a darker bottom, and + * the semantic tones are tuned for text rather than for filling a whole shape: + * the dark-scheme tones especially are deliberately desaturated, which reads + * washed out once a disc or a glyph carries them. The badge and the sticker + * therefore share one fill rule, so the two sit next to each other with the same + * green or red. + * + * The rule needs no scheme switch of its own: a tone whose value is already high + * is a dark-scheme tone, and its gradient is pulled down instead of up, because + * filling a shape with a tone that light is what reads pale. + */ +namespace ThemeIconFill +{ +/** + * @brief Returns the tone with a saturation floor, for a filled shape. + * + * The hue and value are left alone, so a light-scheme tone stays as dark as the + * line glyph next to it, and an achromatic tone stays gray. + * + * @param tone Glyph color the treatment is rendered with. + * @return The tone, saturated enough to carry a whole shape. + */ +QColor saturated(const QColor &tone); + +/** + * @brief True when @p tone is a light tone, as the dark scheme's palette is. + * + * The fill branches on this: a tone this light is darkened for a filled shape, + * and the badge keeps its darkened ink on it instead of the light scheme's + * white glyph. + * + * @param tone Glyph color the treatment is rendered with. + */ +bool isDarkSchemeTone(const QColor &tone); + +/** + * @brief Returns the top color of the fill gradient. + * @param tone Glyph color the treatment is rendered with. + * @return The lighter end of the gradient. + */ +QColor topColor(const QColor &tone); + +/** + * @brief Returns the bottom color of the fill gradient. + * @param tone Glyph color the treatment is rendered with. + * @return The darker end of the gradient. + */ +QColor bottomColor(const QColor &tone); +} // namespace ThemeIconFill + +#endif // THEME_ICON_FILL_H diff --git a/cockatrice/src/interface/theme_icon_sticker.cpp b/cockatrice/src/interface/theme_icon_sticker.cpp new file mode 100644 index 000000000..c5f86c7ac --- /dev/null +++ b/cockatrice/src/interface/theme_icon_sticker.cpp @@ -0,0 +1,87 @@ +#include "theme_icon_sticker.h" + +#include "libcockatrice/utility/svg_recolor.h" +#include "theme_icon_fill.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +// The pre-#7595 add/remove art filled about 80% of the canvas: the sentinel +// paths are padded inside their 24-unit viewBox, so the drawing box has to be +// slightly larger than the canvas for the ink to reach that far. +constexpr qreal STICKER_SCALE = 1.06; +// Old arm thickness was about a fifth of the canvas, with a rim near 5%. In the +// 24-unit viewBox that is a 4.5-unit band with a 0.9-unit rim on each side. +constexpr qreal STICKER_STROKE_WIDTH = 4.5; +constexpr qreal STICKER_OUTLINE_WIDTH = 0.9; +// The old art's outline was black. Darkening by 300 reads as black on a light +// scheme (the shipped tones are already dark there) while staying a visible dark +// rim on a dark one, where a neutral black would vanish into the window. +constexpr int OUTLINE_DARKER = 300; +} // namespace + +namespace ThemeIconSticker +{ +QColor outlineColor(const QColor &tone) +{ + return ThemeIconFill::saturated(tone).darker(OUTLINE_DARKER); +} + +QPixmap renderSticker(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 box = side * STICKER_SCALE; + const QRectF glyphRect(QPointF((pixelSize.width() - box) / 2.0, (pixelSize.height() - box) / 2.0), + QSizeF(box, box)); + + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + + // Outline first, at the band width plus one rim on each side; the fill pass + // then covers the middle and leaves only the rim visible. + const QByteArray outlineSvg = + resolveSvgStrokeWidth(resolveSvgColorSentinel(svg, outlineColor(tone).name(QColor::HexRgb).toLatin1()), + STICKER_STROKE_WIDTH + 2.0 * STICKER_OUTLINE_WIDTH); + QSvgRenderer outlineRenderer(outlineSvg); + if (outlineRenderer.isValid()) { + outlineRenderer.render(&painter, glyphRect); + } + + // The fill needs its own pass so the gradient can be clipped to the glyph: + // painting it onto the composited pixmap would tint the outline as well. + QPixmap fillPixmap(pixelSize); + fillPixmap.fill(Qt::transparent); + { + QPainter fillPainter(&fillPixmap); + fillPainter.setRenderHint(QPainter::Antialiasing, true); + + const QByteArray fillSvg = resolveSvgStrokeWidth( + resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()), STICKER_STROKE_WIDTH); + QSvgRenderer fillRenderer(fillSvg); + if (fillRenderer.isValid()) { + fillRenderer.render(&fillPainter, glyphRect); + fillPainter.setCompositionMode(QPainter::CompositionMode_SourceIn); + QLinearGradient gradient(glyphRect.topLeft(), glyphRect.bottomLeft()); + gradient.setColorAt(0.0, ThemeIconFill::topColor(tone)); + gradient.setColorAt(1.0, ThemeIconFill::bottomColor(tone)); + fillPainter.fillRect(QRectF(QPointF(0, 0), QSizeF(pixelSize)), gradient); + } + } + painter.drawPixmap(0, 0, fillPixmap); + + return pixmap; +} +} // namespace ThemeIconSticker diff --git a/cockatrice/src/interface/theme_icon_sticker.h b/cockatrice/src/interface/theme_icon_sticker.h new file mode 100644 index 000000000..a2ad51bb7 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_sticker.h @@ -0,0 +1,62 @@ +/** + * @file theme_icon_sticker.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_STICKER_H +#define THEME_ICON_STICKER_H + +#include +#include +#include +#include + +/** + * @brief Paints the sticker treatment of a `currentColor` glyph. + * + * The pre-#7595 add, remove and delete icons were filled shapes with a black + * outline, not discs. A tint alone cannot bring that back: it produces a + * hairline, and a badge disc would be a different icon language. The sticker + * treatment rebuilds the look from the same sentinel art by drawing the glyph + * twice, once with a dark outline and once with a vertical tone gradient on top. + * + * The trade-off is weight against crispness. A sticker is louder than a line, + * which is what an add or a destructive action wants, but it needs a size where + * the tone band is at least two device pixels wide. Callers opt in; navigation + * and menus keep the line treatment. + * + * Everything here works in device pixels, like the badge treatment: the engine + * renders at the size it already computed and attaches the device pixel ratio + * afterwards. + */ +namespace ThemeIconSticker +{ +/** + * @brief Returns the dark rim color the sticker is painted with. + * + * The old art had a black outline. Deriving it from the tone reads as black on a + * light scheme without a literal, and stays visible on a dark one, where a pure + * black rim would disappear into the window. + * + * @param tone Glyph color the sticker is rendered with. + * @return A much darker version of the tone. + */ +QColor outlineColor(const QColor &tone); + +/** + * @brief Renders a sentinel-carrying SVG as a filled glyph with a black outline. + * + * The glyph is drawn larger than the canvas so the padded sentinel paths fill + * the icon the way the old art did, with a vertical tone gradient as the fill + * and a near-black rim. Authored art is never drawn through this path. + * + * @param svg Sentinel-carrying SVG source; `currentColor` is resolved internally. + * @param tone Glyph color, already resolved for the icon mode. + * @param pixelSize Canvas size in device pixels; a non-square canvas centers + * the glyph and leaves the remaining area transparent. + * @return The composited sticker, or a null pixmap for empty or invalid input. + */ +QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize); +} // namespace ThemeIconSticker + +#endif // THEME_ICON_STICKER_H diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index f35126753..5df8db7cc 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -248,19 +248,19 @@ void DeckEditorDeckDockWidget::createDeckDock() &DeckEditorDeckDockWidget::applyActiveGroupCriteria); aIncrement = new QAction(QString(), this); - aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive)); + aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker)); connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection); auto *tbIncrement = new QToolButton(this); tbIncrement->setDefaultAction(aIncrement); aDecrement = new QAction(QString(), this); - aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative)); + aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection); auto *tbDecrement = new QToolButton(this); tbDecrement->setDefaultAction(aDecrement); aRemoveCard = new QAction(QString(), this); - aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative)); + aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard); auto *tbRemoveCard = new QToolButton(this); tbRemoveCard->setDefaultAction(aRemoveCard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index 9621538e1..33a9cd63c 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -64,7 +64,7 @@ void DeckEditorFilterDockWidget::createFiltersDock() connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter); aClearFilterOne = new QAction(QString(), this); - aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative)); + aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); aClearFilterAll = new QAction(QString(), this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index b698404df..a30076519 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -38,7 +38,8 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative)); + btnDeleteServer->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index d086404f8..a6d337a56 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -108,10 +108,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul &DlgEditTokens::tokenSelectionChanged); QAction *aAddToken = new QAction(tr("Add token"), this); - aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive)); + aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker)); connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken); QAction *aRemoveToken = new QAction(tr("Remove token"), this); - aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative)); + aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken); auto *databaseToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index 1f7bb5d25..0b6a9585d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -34,7 +34,8 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative)); + btnDeleteServer->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index 330830efb..6e104d617 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -80,7 +80,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive)); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker)); connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL); aEdit = new QAction(this); @@ -88,7 +88,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL); aRemove = new QAction(this); - aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative)); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL); aRateLimit = new QAction(this); diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index 74af001f3..ad6651ffa 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -150,7 +150,7 @@ MessagesSettingsPage::MessagesSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive)); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker)); connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd); aEdit = new QAction(this); @@ -158,7 +158,7 @@ MessagesSettingsPage::MessagesSettingsPage() connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit); aRemove = new QAction(this); - aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative)); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove); auto *messageToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 06b37fc8c..9e031c79a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -199,7 +199,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder); aDeleteLocalDeck = new QAction(this); - aDeleteLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative)); + aDeleteLocalDeck->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck); aOpenDecksFolder = new QAction(this); @@ -217,7 +218,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder); aDeleteRemoteDeck = new QAction(this); - aDeleteRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative)); + aDeleteRemoteDeck->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); aShareDecks = new QAction(this); diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index 483d33238..3868ee75f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -155,7 +155,8 @@ QGroupBox *TabReplays::createLeftLayout() aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder); aDeleteLocalReplay = new QAction(this); - aDeleteLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative)); + aDeleteLocalReplay->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay); aOpenReplaysFolder = new QAction(this); @@ -217,7 +218,8 @@ QGroupBox *TabReplays::createRightLayout() aKeep->setIcon(loadThemedIcon(QStringLiteral("icons/lock"))); connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay); aDeleteRemoteReplay = new QAction(this); - aDeleteRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative)); + aDeleteRemoteReplay->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); aGetReplayCode = new QAction(this); aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp index 041e289fe..ad51388d2 100644 --- a/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp @@ -42,3 +42,46 @@ QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &colo } return resolved; } + +QByteArray resolveSvgStrokeWidth(const QByteArray &svg, double width) +{ + constexpr QByteArrayView ATTRIBUTE = "stroke-width"; + constexpr QByteArrayView DOUBLE_QUOTE = "\""; + constexpr QByteArrayView SINGLE_QUOTE = "'"; + const QByteArray value = QByteArray::number(width, 'f', 2); + + 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; + } + + // Only an XML attribute is rewritten. A CSS declaration such as + // `style="stroke-width:2"` has no `=` right after the name, so it stays + // untouched and the scan moves past it. + qsizetype nameEnd = start + ATTRIBUTE.size(); + while (nameEnd < svg.size() && svg.at(nameEnd) == ' ') { + ++nameEnd; + } + const bool isAttribute = nameEnd < svg.size() && svg.at(nameEnd) == '=' && nameEnd + 1 < svg.size(); + const char quoteChar = isAttribute ? svg.at(nameEnd + 1) : '\0'; + const bool quoted = quoteChar == '"' || quoteChar == '\''; + const qsizetype close = quoted ? svg.indexOf(quoteChar == '"' ? DOUBLE_QUOTE : SINGLE_QUOTE, nameEnd + 2) : -1; + if (close < 0) { + resolved.append(svg.sliced(cursor, start + ATTRIBUTE.size() - cursor)); + cursor = start + ATTRIBUTE.size(); + continue; + } + + // Keep the opening quote, replace the value, and resume at the closing + // quote so the rest of the document is copied verbatim. + resolved.append(svg.sliced(cursor, nameEnd + 2 - cursor)); + resolved.append(value); + cursor = close; + } +} diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.h b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h index 56d609b87..e55f1c775 100644 --- a/libcockatrice_utility/libcockatrice/utility/svg_recolor.h +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h @@ -28,4 +28,21 @@ */ QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color); +/** + * @brief Rewrites the value of every `stroke-width` attribute in authored SVG. + * + * A treatment can need a heavier or lighter stroke than the one authored for a + * line icon at text size. Like the sentinel resolver, this rewriter works on the + * bytes and understands only the XML attribute form, in either quote style + * (`stroke-width="2"` or `stroke-width='2'`): a CSS declaration such as + * `style="stroke-width:2"` is left untouched. Documents without the attribute + * are returned unchanged. + * + * @param svg SVG source bytes, expected to be UTF-8. Invalid UTF-8 is preserved + * byte-for-byte. + * @param width Replacement width in the document's user units. + * @return The rewritten SVG source; the input is left untouched. + */ +QByteArray resolveSvgStrokeWidth(const QByteArray &svg, double width); + #endif // SVG_RECOLOR_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 1d1703f6f..50799d496 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -33,6 +33,8 @@ set(oracle_SOURCES ../cockatrice/src/interface/theme_config.cpp ../cockatrice/src/interface/theme_icon.cpp ../cockatrice/src/interface/theme_icon_badge.cpp + ../cockatrice/src/interface/theme_icon_fill.cpp + ../cockatrice/src/interface/theme_icon_sticker.cpp ../cockatrice/src/interface/theme_manager.cpp ${VERSION_STRING_CPP} ) diff --git a/tests/svg_recolor_test.cpp b/tests/svg_recolor_test.cpp index abe1e8d1a..ada58a7a7 100644 --- a/tests/svg_recolor_test.cpp +++ b/tests/svg_recolor_test.cpp @@ -63,4 +63,57 @@ TEST(SvgRecolor, NoSentinelIsANoOp) EXPECT_EQ(resolveSvgColorSentinel(svg, "#ffffff"), svg); } +TEST(SvgStrokeWidth, RewritesEveryAttribute) +{ + const QByteArray svg = R"()"; + + const QByteArray result = resolveSvgStrokeWidth(svg, 4.25); + + EXPECT_EQ(result.count(R"(stroke-width="4.25")"), 2); + EXPECT_EQ(result.count(R"(stroke-width='4.25')"), 1); + EXPECT_FALSE(result.contains("1.5")); + EXPECT_FALSE(result.contains("3'/")); +} + +TEST(SvgStrokeWidth, LeavesCssDeclarationsAlone) +{ + const QByteArray svg = R"()"; + + const QByteArray result = resolveSvgStrokeWidth(svg, 3.0); + + EXPECT_TRUE(result.contains(R"(style="stroke-width:2")")); + EXPECT_EQ(result.count(R"(stroke-width="3.00")"), 1); +} + +TEST(SvgStrokeWidth, NoAttributeIsANoOp) +{ + const QByteArray svg = R"()"; + + EXPECT_EQ(resolveSvgStrokeWidth(svg, 3.0), svg); +} + +TEST(SvgStrokeWidth, DoesNotModifyTheInputAndPreservesBytes) +{ + QByteArray svg = ""; + svg.append("\xff\xfe"); + svg.append(R"()"); + const QByteArray original = svg; + + const QByteArray result = resolveSvgStrokeWidth(svg, 3.5); + + EXPECT_EQ(svg, original); + EXPECT_TRUE(result.contains("\xff\xfe")); + EXPECT_TRUE(result.contains(R"(stroke-width="3.50")")); +} + +TEST(SvgStrokeWidth, TruncatedAttributeTerminates) +{ + // A quote that is never closed must not over-read or hang. + const QByteArray svg = R"(")); +} + } // namespace diff --git a/tests/theme_migration/CMakeLists.txt b/tests/theme_migration/CMakeLists.txt index 61649e73b..2e65bce1e 100644 --- a/tests/theme_migration/CMakeLists.txt +++ b/tests/theme_migration/CMakeLists.txt @@ -7,6 +7,8 @@ cockatrice_add_test( ${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/interface/theme_icon_fill.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_sticker.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 493e6a3a1..c03b91703 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -1,5 +1,7 @@ #include "interface/theme_icon.h" #include "interface/theme_icon_badge.h" +#include "interface/theme_icon_fill.h" +#include "interface/theme_icon_sticker.h" #include "interface/theme_manager.h" #include "libcockatrice/utility/svg_recolor.h" @@ -48,6 +50,14 @@ QString resourcesDir() return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources")); } +/** @brief Reads a sentinel SVG from the shipped icon set. */ +QByteArray readIconSvg(const char *name) +{ + QFile file(QDir(resourcesDir()) + .absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg"))); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); +} + int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) { const QImage image = pixmap.toImage(); @@ -64,6 +74,23 @@ int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) return count; } +/** @brief Opaque pixels that are not the given rim color, i.e. the gradient fill. */ +int countOpaquePixelsOtherThan(const QPixmap &pixmap, const QColor &excluded) +{ + const QImage image = pixmap.toImage(); + int count = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor pixel = image.pixelColor(x, y); + if (pixel.alpha() > 200 && (pixel.red() != excluded.red() || pixel.green() != excluded.green() || + pixel.blue() != excluded.blue())) { + ++count; + } + } + } + return count; +} + QByteArray svgWith(const QByteArray &colorToken) { return QByteArrayLiteral(R"(appColor(AppColor::Positive)); } +/** + * @brief Line glyphs share the filled treatments' saturation floor. + * + * The dark-scheme semantic tones are the visible case, but `AccentSoft` is below + * the floor in every shipped light palette, so the resolve path is exercised + * without swapping the theme. A line glyph next to a badge or a sticker has to + * carry the same tone; reverting the floor here would leave it pale beside them. + */ +TEST_F(ThemeIconRenderTest, LineTintAppliesTheFillSaturationFloor) +{ + const QColor raw = themeManager->appColor(AppColor::AccentSoft); + ASSERT_TRUE(raw.isValid()); + ASSERT_LT(raw.hsvSaturation(), 170); + + const QColor saturated = ThemeIconFill::saturated(raw); + EXPECT_NE(saturated, raw); + EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Normal), saturated); + + const QIcon icon = loadThemedIcon(QStringLiteral("icons/arrow_right"), AppColor::AccentSoft); + const QPixmap pixmap = icon.pixmap(QSize(24, 24)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_GT(countOpaquePixels(pixmap, saturated), 0); + EXPECT_EQ(countOpaquePixels(pixmap, raw), 0); +} + /** @brief A badge is a disc with a gradient fill, a dark ring and a contrast glyph. */ TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing) { @@ -295,8 +347,8 @@ TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing) 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 ring is the saturated tone darkened, and it must actually be painted. + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(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. @@ -311,6 +363,73 @@ TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing) EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0); } +/** + * @brief The dark-scheme disc carries the same saturated fill as the sticker. + * + * The dark tones are tuned for text and read as pale mint or salmon once they + * fill a disc, so the badge saturates its fill. The dark scheme keeps the + * darkened ink the badge had before the fill moved to the sticker ramp. + */ +TEST_F(ThemeIconRenderTest, BadgeDarkSchemeDiscUsesTheSaturatedFill) +{ + const QColor tone = defaultAppColor(AppColor::Positive, true); + ASSERT_TRUE(tone.isValid()); + ASSERT_GE(tone.value(), 200); + + const QPixmap pixmap = ThemeIconBadge::renderBadge(readIconSvg("ready_start"), tone, QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + const QImage image = pixmap.toImage(); + + // The disc neither keeps the pale raw tone nor the old unsaturating bottom. + EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(104)), 0); + const QColor top = image.pixelColor(10, 16); + const QColor bottom = image.pixelColor(10, 48); + ASSERT_GT(top.alpha(), 200); + ASSERT_GT(bottom.alpha(), 200); + // Antialiasing costs a point or two of saturation at the sample points. + EXPECT_GE(top.hsvSaturation(), 160); + EXPECT_GE(bottom.hsvSaturation(), 160); + EXPECT_GT(top.lightness(), bottom.lightness()); + + // The dark scheme keeps the darkened ink, and it is actually painted. + const QColor ink = ThemeIconBadge::glyphColor(tone); + ASSERT_NE(ink, QColor(Qt::white)); + EXPECT_GT(countOpaquePixels(pixmap, ink), 0); +} + +/** + * @brief The badge ink follows the scheme. + * + * Light-scheme tones keep the white glyph of the old art; dark-scheme tones + * keep the darkened ink, because the shared saturated disc is light enough that + * white would sit near 2.7:1 at its top. Pinning both cases stops the glyph from + * silently flipping back to white when the fill or the palette changes. + */ +TEST_F(ThemeIconRenderTest, BadgeInkFollowsTheScheme) +{ + const struct + { + AppColor::Role role; + bool dark; + } cases[] = {{AppColor::Positive, false}, + {AppColor::Negative, false}, + {AppColor::Positive, true}, + {AppColor::Negative, true}}; + + for (const auto &testCase : cases) { + const QColor tone = defaultAppColor(testCase.role, testCase.dark); + ASSERT_TRUE(tone.isValid()); + const QColor ink = ThemeIconBadge::glyphColor(tone); + if (testCase.dark) { + EXPECT_NE(ink, QColor(Qt::white)); + EXPECT_LT(ink.value(), 100); + } else { + EXPECT_EQ(ink, QColor(Qt::white)); + } + } +} + /** @brief The badge is rendered at the device-pixel size, not upscaled later. */ TEST_F(ThemeIconRenderTest, BadgeHonoursDevicePixelRatio) { @@ -355,7 +474,7 @@ TEST_F(ThemeIconRenderTest, SentinelSvgBadgeMatchesTheEngine) 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, ThemeIconFill::saturated(tone).darker(300)), 0); EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0); } @@ -378,7 +497,7 @@ TEST_F(ThemeIconRenderTest, BadgeOnAuthoredArtUsesTheAssetPath) // 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); + EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); } /** @brief Disabled keeps the shape language but drops back to the line glyph. */ @@ -396,12 +515,153 @@ TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph) // 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, ThemeIconFill::saturated(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 Sticker fills the glyph with the tone and rims it with a dark outline. + * + * The old add/remove art was a filled shape with a black outline and no disc, so + * the sticker must show a solid tone interior, a dark rim, and transparent + * corners. + */ +TEST_F(ThemeIconRenderTest, StickerPaintsToneFillWithDarkOutline) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker); + const QPixmap pixmap = icon.pixmap(QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(64, 64)); + + const QImage image = pixmap.toImage(); + // No disc: the corners stay transparent, unlike the badge. + EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); + EXPECT_EQ(image.pixelColor(63, 63).alpha(), 0); + + // The glyph is filled: the vertical arm carries a gradient, lighter at the + // top and darker at the bottom, with the rim painted in the darkened tone. + const QColor top = image.pixelColor(32, 12); + const QColor bottom = image.pixelColor(32, 52); + ASSERT_GT(top.alpha(), 200); + ASSERT_GT(bottom.alpha(), 200); + EXPECT_GT(top.lightness(), bottom.lightness()); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 20); +} + +/** + * @brief The dark-scheme fill is the branch the treatment exists for. + * + * That tone is light and deliberately desaturated, so a plain gradient paints a + * pale mint shape. The fill has to raise its saturation and pull its value down + * instead of up. + */ +TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillIsSaturatedAndDarkerThanTheTone) +{ + const QColor tone = defaultAppColor(AppColor::Positive, true); + ASSERT_TRUE(tone.isValid()); + ASSERT_GE(tone.value(), 200); + + const QPixmap pixmap = ThemeIconSticker::renderSticker(readIconSvg("increment"), tone, QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + const QImage image = pixmap.toImage(); + + const QColor top = image.pixelColor(32, 14); + const QColor bottom = image.pixelColor(32, 50); + ASSERT_GT(top.alpha(), 200); + ASSERT_GT(bottom.alpha(), 200); + EXPECT_LT(top.value(), tone.value()); + EXPECT_LT(bottom.value(), tone.value()); + EXPECT_GT(top.lightness(), bottom.lightness()); + EXPECT_GE(top.hsvSaturation(), 170); +} + +/** @brief The sticker is rendered at the device-pixel size, not upscaled later. */ +TEST_F(ThemeIconRenderTest, StickerHonoursDevicePixelRatio) +{ + const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker); + 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 A sticker has to survive the 16px toolbar slot. + * + * A 15/16px canvas leaves a three-pixel band. The tone interior must still be + * solid ink rather than an antialiased smudge, and the icon must not turn into a + * disc. + */ +TEST_F(ThemeIconRenderTest, StickerStaysLegibleAtToolbarSize) +{ + const QColor tone = themeManager->appColor(AppColor::Negative); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker); + const QPixmap pixmap = icon.pixmap(QSize(16, 16)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(16, 16)); + + const QImage image = pixmap.toImage(); + EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); + EXPECT_GT(countOpaquePixelsOtherThan(pixmap, ThemeIconSticker::outlineColor(tone)), 8); +} + +/** @brief The pixmap API's sticker path agrees with the engine's. */ +TEST_F(ThemeIconRenderTest, SentinelSvgStickerMatchesTheEngine) +{ + const QColor tone = themeManager->appColor(AppColor::Negative); + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/remove_row.svg"), AppColor::Negative, + QSize(48, 48), IconStyle::Sticker); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(48, 48)); + EXPECT_GT(countOpaquePixelsOtherThan(pixmap, ThemeIconSticker::outlineColor(tone)), 0); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0); +} + +/** @brief Sticker never paints authored art: the asset keeps its own colors. */ +TEST_F(ThemeIconRenderTest, StickerOnAuthoredArtUsesTheAssetPath) +{ + 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::Negative); + const QIcon icon = loadThemedIcon(QStringLiteral("plain"), AppColor::Negative, IconStyle::Sticker); + const QPixmap pixmap = icon.pixmap(QSize(32, 32)); + ASSERT_FALSE(pixmap.isNull()); + + EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); + EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0); +} + +/** @brief Disabled keeps the shape language but drops back to the line glyph. */ +TEST_F(ThemeIconRenderTest, DisabledStickerFallsBackToLineGlyph) +{ + 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::Negative); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker); + const QPixmap pixmap = icon.pixmap(QSize(64, 64), QIcon::Disabled); + ASSERT_FALSE(pixmap.isNull()); + + // No rim in the sticker tone: Disabled drops the treatment and paints the + // thin disabled line glyph instead. + EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0); + EXPECT_GT(countOpaquePixels(pixmap, disabledText), 0); +} + /** @brief Line stays the default: no disc, no ring, just the recolored glyph. */ TEST_F(ThemeIconRenderTest, LineStyleStillRendersGlyphAlone) { @@ -410,18 +670,279 @@ TEST_F(ThemeIconRenderTest, LineStyleStillRendersGlyphAlone) loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive).pixmap(QSize(64, 64)); ASSERT_FALSE(pixmap.isNull()); - EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); EXPECT_GT(countOpaquePixels(pixmap, tone), 0); } +/** + * @brief Writes the semantic-tint preview sheet when asked; skipped otherwise. + * + * #7775 put the semantic tone back on line glyphs, before any filled treatment + * existed. The sheet renders the affected glyphs with the raw per-scheme + * `AppColor` value, which is what that PR paints, at the sizes the UI uses. + */ +TEST(IconTintPreview, 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 tinted[] = { + {"ready_start", AppColor::Positive}, {"not_ready_start", AppColor::Negative}, {"increment", AppColor::Positive}, + {"decrement", AppColor::Negative}, {"remove_row", AppColor::Negative}, {"delete", AppColor::Negative}, + {"arrow_right", AppColor::Positive}, + }; + constexpr int SIZES[] = {15, 16, 24, 48}; + + 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 = 130; + constexpr int CELL_WIDTH = 120; + constexpr int ROW_HEIGHT = 70; + constexpr qreal DPR = 2.0; + + const int logicalWidth = MARGIN + LABEL_WIDTH + static_cast(std::size(SIZES)) * CELL_WIDTH + MARGIN; + const int logicalHeight = MARGIN + 24 + 34 + static_cast(std::size(tinted)) * ROW_HEIGHT + 40 + 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("Semantic tints on line glyphs (#7775)")); + y += 10; + + for (const Glyph &glyph : tinted) { + y += ROW_HEIGHT; + const qreal rowTop = y - ROW_HEIGHT; + QFile file(QDir(resourcesDir()) + .absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(glyph.name) + + QStringLiteral(".svg"))); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)) << glyph.name; + const QByteArray svg = file.readAll(); + const QColor tone = defaultAppColor(glyph.role, dark); + painter.drawText(QPointF(MARGIN, rowTop + 16), QString::fromLatin1(glyph.name)); + for (std::size_t i = 0; i < std::size(SIZES); ++i) { + const int side = SIZES[i]; + const QPixmap pixmap = renderLine(svg, tone, QSize(side, side)); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; + // Bottom-align the sizes and put the caption below the 48px row. + painter.drawPixmap(QPointF(x, rowTop + 54 - side), pixmap); + painter.drawText(QPointF(x, rowTop + 66), QStringLiteral("%1px").arg(side)); + } + } + + y += 20; + painter.drawText(QPointF(MARGIN, y), + QStringLiteral("Positive: ready and add. Negative: not ready, remove and delete.")); + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("tint-preview-dark.png") + : QStringLiteral("tint-preview-light.png")); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} + +/** + * @brief Writes the sticker preview sheet when asked; skipped otherwise. + * + * The sheet shows each add/remove glyph as line and as sticker at the + * sizes the UI uses (15px in the player list and menus, 16px in toolbars, 24 and + * 48 for inspection), with the ready/not-ready badge for context. It lives in the + * test binary so the colors come from the same place the engine uses. + */ +TEST(IconStickerPreview, 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 stickered[] = { + {"increment", AppColor::Positive}, + {"decrement", AppColor::Negative}, + {"remove_row", AppColor::Negative}, + }; + constexpr int SIZES[] = {15, 16, 24, 48}; + constexpr int CONTEXT_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 = 110; + 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(stickered)) * GLYPH_BLOCK_HEIGHT + 24 + 24 + + CONTEXT_SIZE + 46 + CONTEXT_SIZE + 46 + 20 + 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("Add / remove (line above, sticker below)")); + for (const Glyph &glyph : stickered) { + y += GLYPH_BLOCK_HEIGHT; + const QByteArray svg = readSvg(glyph.name); + ASSERT_FALSE(svg.isEmpty()) << glyph.name; + // The preview must show the line glyph the engine paints, and the + // engine saturates AppColor tones before drawing them. + const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark)); + + for (int row = 0; row < 2; ++row) { + const bool sticker = row == 1; + const qreal rowTop = y - GLYPH_BLOCK_HEIGHT + 22 + row * ROW_HEIGHT; + painter.drawText(QPointF(MARGIN, rowTop + 16), + (sticker ? QStringLiteral("sticker ") : 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 = sticker ? ThemeIconSticker::renderSticker(svg, tone, QSize(side, side)) + : renderLine(svg, tone, QSize(side, side)); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; + 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("Ready / not ready keep the badge (%1px)").arg(CONTEXT_SIZE)); + constexpr Glyph badgeContext[] = { + {"ready_start", AppColor::Positive}, + {"not_ready_start", AppColor::Negative}, + }; + for (std::size_t i = 0; i < std::size(badgeContext); ++i) { + const QByteArray svg = readSvg(badgeContext[i].name); + ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name; + const QColor tone = defaultAppColor(badgeContext[i].role, dark); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; + painter.drawPixmap(QPointF(x, y + 8), + ThemeIconBadge::renderBadge(svg, tone, QSize(CONTEXT_SIZE, CONTEXT_SIZE))); + painter.drawText(QPointF(x, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(badgeContext[i].name)); + } + + y += CONTEXT_SIZE + 46; + painter.drawText(QPointF(MARGIN, y), + QStringLiteral("Line / badge / sticker share one tone (%1px)").arg(CONTEXT_SIZE)); + constexpr const char *FAMILY_LABELS[] = {"line", "badge", "sticker"}; + for (std::size_t i = 0; i < std::size(badgeContext); ++i) { + const QByteArray svg = readSvg(badgeContext[i].name); + ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name; + const QColor tone = ThemeIconFill::saturated(defaultAppColor(badgeContext[i].role, dark)); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH * 1.5; + for (int j = 0; j < 3; ++j) { + const QSize side(CONTEXT_SIZE, CONTEXT_SIZE); + QPixmap pixmap; + if (j == 0) { + pixmap = renderLine(svg, tone, side); + } else if (j == 1) { + pixmap = ThemeIconBadge::renderBadge(svg, tone, side); + } else { + pixmap = ThemeIconSticker::renderSticker(svg, tone, side); + } + const qreal px = x + j * (CONTEXT_SIZE + 16); + painter.drawPixmap(QPointF(px, y + 8), pixmap); + painter.drawText(QPointF(px, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(FAMILY_LABELS[j])); + } + } + + y += CONTEXT_SIZE + 46; + painter.drawText( + QPointF(MARGIN, y), + QStringLiteral( + "The clear-filter trash can keeps the line treatment; a sticker rim closes its lid at toolbar sizes.")); + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("sticker-preview-dark.png") + : QStringLiteral("sticker-preview-light.png")); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} + /** * @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. + * inspection). It lives in the test binary so the colors come from the same + * place the engine uses. The add/remove/delete glyphs and their sticker + * treatment have their own sheet (IconStickerPreview). */ TEST(IconBadgePreview, WritesPngsWhenRequested) { @@ -443,14 +964,7 @@ TEST(IconBadgePreview, WritesPngsWhenRequested) {"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( @@ -476,8 +990,7 @@ TEST(IconBadgePreview, WritesPngsWhenRequested) 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 int logicalHeight = MARGIN + 24 + 24 + static_cast(std::size(badged)) * GLYPH_BLOCK_HEIGHT + 30 + MARGIN; const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)); for (const bool dark : {false, true}) { @@ -502,7 +1015,9 @@ TEST(IconBadgePreview, WritesPngsWhenRequested) y += GLYPH_BLOCK_HEIGHT; const QByteArray svg = readSvg(glyph.name); ASSERT_FALSE(svg.isEmpty()) << glyph.name; - const QColor tone = defaultAppColor(glyph.role, dark); + // The preview must show the line glyph the engine paints, and the + // engine saturates AppColor tones before drawing them. + const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark)); for (int row = 0; row < 2; ++row) { const bool badge = row == 1; @@ -525,18 +1040,6 @@ TEST(IconBadgePreview, WritesPngsWhenRequested) } } - 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"));