mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-07 10:33:24 -08:00
[Icons] Add a sticker icon treatment
The pre-#7595 add, remove and delete icons were filled shapes with a black outline and no disc. A tint cannot bring that back, and the badge disc is a different icon language, so add a third value to the existing style axis. IconStyle::Sticker draws the sentinel glyph twice: a near-black rim at the band width plus one rim on each side, then the tone pass clipped to the glyph and filled with a vertical gradient. Both treatments keep the disabled fallback to the line glyph. The fill rule moves into theme_icon_fill: the semantic tones are tuned for text, so a filled shape gives its tone a saturation floor, and a dark-scheme tone is pulled down rather than up. IconTint::resolve() applies the same floor to line glyphs, and the badge disc uses the same fill, so a line, a badge and a sticker next to each other carry the same green or red instead of a pale line beside a saturated glyph. The badge's glyph ink follows the scheme: white on light-scheme tones, as in the pre-#7595 art, and the saturated tone darkened to an outline-dark ink on dark-scheme tones, where the shared saturated disc is light enough that white would sit near 2.7:1. The new test pins both cases so the glyph cannot silently flip. resolveSvgStrokeWidth() moves the stroke-width rewrite into libcockatrice_utility so the badge and the sticker share it; the badge's local copy is gone. The sticker is opted into at 17 call sites: increment, decrement and remove_row in the deck editor, settings pages, token dialog, server dialogs and replay/deck storage tabs. The visual database's clear-filter button keeps its trash can (icons/delete) in the line treatment: that glyph is new in #7595, but it is the clearer metaphor, and a sticker rim closes its lid gap at toolbar sizes. The preview generators also gain a semantic-tint sheet for #7775 and a line/badge/sticker family strip, so the three PR descriptions can show the same rendered colors.
This commit is contained in:
parent
717f64a88b
commit
876d66ffc9
25 changed files with 1027 additions and 127 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent)
|
|||
typeCombo->addItem(CardFilter::typeName(static_cast<CardFilter::Type>(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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <QApplication>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#include "theme_icon_badge.h"
|
||||
|
||||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
#include "theme_icon_fill.h"
|
||||
|
||||
#include <QLinearGradient>
|
||||
#include <QPainter>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
61
cockatrice/src/interface/theme_icon_fill.cpp
Normal file
61
cockatrice/src/interface/theme_icon_fill.cpp
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
#include "theme_icon_fill.h"
|
||||
|
||||
#include <QtGlobal>
|
||||
|
||||
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
|
||||
64
cockatrice/src/interface/theme_icon_fill.h
Normal file
64
cockatrice/src/interface/theme_icon_fill.h
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* @file theme_icon_fill.h
|
||||
* @ingroup Interface
|
||||
*/
|
||||
|
||||
#ifndef THEME_ICON_FILL_H
|
||||
#define THEME_ICON_FILL_H
|
||||
|
||||
#include <QColor>
|
||||
|
||||
/**
|
||||
* @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
|
||||
87
cockatrice/src/interface/theme_icon_sticker.cpp
Normal file
87
cockatrice/src/interface/theme_icon_sticker.cpp
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#include "theme_icon_sticker.h"
|
||||
|
||||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
#include "theme_icon_fill.h"
|
||||
|
||||
#include <QLinearGradient>
|
||||
#include <QPainter>
|
||||
#include <QPointF>
|
||||
#include <QRectF>
|
||||
#include <QSvgRenderer>
|
||||
#include <QtGlobal>
|
||||
|
||||
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
|
||||
62
cockatrice/src/interface/theme_icon_sticker.h
Normal file
62
cockatrice/src/interface/theme_icon_sticker.h
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* @file theme_icon_sticker.h
|
||||
* @ingroup Interface
|
||||
*/
|
||||
|
||||
#ifndef THEME_ICON_STICKER_H
|
||||
#define THEME_ICON_STICKER_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QColor>
|
||||
#include <QPixmap>
|
||||
#include <QSize>
|
||||
|
||||
/**
|
||||
* @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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -63,4 +63,57 @@ TEST(SvgRecolor, NoSentinelIsANoOp)
|
|||
EXPECT_EQ(resolveSvgColorSentinel(svg, "#ffffff"), svg);
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, RewritesEveryAttribute)
|
||||
{
|
||||
const QByteArray svg = R"(<svg stroke-width="2"><path stroke-width="1.5"/><path stroke-width='3'/></svg>)";
|
||||
|
||||
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"(<svg><path style="stroke-width:2"/><path stroke-width="2"/></svg>)";
|
||||
|
||||
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"(<svg stroke-linecap="round"><path d="M0 0h24"/></svg>)";
|
||||
|
||||
EXPECT_EQ(resolveSvgStrokeWidth(svg, 3.0), svg);
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, DoesNotModifyTheInputAndPreservesBytes)
|
||||
{
|
||||
QByteArray svg = "<svg>";
|
||||
svg.append("\xff\xfe");
|
||||
svg.append(R"(<path stroke-width="2"/></svg>)");
|
||||
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"(<svg><path stroke-width="2/></svg>)";
|
||||
|
||||
const QByteArray result = resolveSvgStrokeWidth(svg, 3.0);
|
||||
|
||||
EXPECT_TRUE(result.startsWith("<svg>"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill=")") +
|
||||
|
|
@ -279,6 +306,31 @@ TEST_F(ThemeIconRenderTest, SelectedAppColorKeepsAContrastingTint)
|
|||
EXPECT_EQ(IconTint(AppColor::Positive).resolve(QIcon::Selected), themeManager->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<int>(std::size(SIZES)) * CELL_WIDTH + MARGIN;
|
||||
const int logicalHeight = MARGIN + 24 + 34 + static_cast<int>(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<qreal>(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<int>(std::size(SIZES)) * CELL_WIDTH + MARGIN;
|
||||
const int logicalHeight = MARGIN + 24 + 24 + static_cast<int>(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<qreal>(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<qreal>(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<qreal>(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<int>(std::size(SIZES)) * CELL_WIDTH + MARGIN;
|
||||
const int logicalHeight = MARGIN + 24 + 24 + static_cast<int>(std::size(badged)) * GLYPH_BLOCK_HEIGHT + 24 + 24 +
|
||||
LINE_GLYPH_SIZE + 30 + MARGIN;
|
||||
const int logicalHeight = MARGIN + 24 + 24 + static_cast<int>(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<qreal>(i) * CELL_WIDTH;
|
||||
painter.drawPixmap(QPointF(x, y + 8), renderLine(svg, tone, QSize(LINE_GLYPH_SIZE, LINE_GLYPH_SIZE)));
|
||||
painter.drawText(QPointF(x, y + 8 + LINE_GLYPH_SIZE + 12), QString::fromLatin1(unchanged[i].name));
|
||||
}
|
||||
|
||||
painter.end();
|
||||
const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("badge-preview-dark.png")
|
||||
: QStringLiteral("badge-preview-light.png"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue