diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 77cb7f014..564e19891 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -152,6 +152,9 @@ set(cockatrice_SOURCES src/interface/pixel_map_generator.cpp src/interface/theme_config.cpp src/interface/theme_icon.cpp + src/interface/theme_icon_badge.cpp + src/interface/theme_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 9c2a91a5b..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")), 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/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 6895e7db8..bc27d85f9 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp @@ -68,8 +68,8 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor, QWidget *parent) : QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false) { - readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start")); - notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start")); + readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start"), AppColor::Negative, IconStyle::Badge); concededIcon = loadThemedIcon(QStringLiteral("icons/conceded")); playerIcon = loadThemedIcon(QStringLiteral("icons/player")); judgeIcon = loadThemedIcon(QStringLiteral("icons/scales")); diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index c98dcbe03..6bcfdc713 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -167,6 +167,10 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch // the lightened, desaturated companion used for button-gradient highlights. cfg.appColors[AppColor::AccentStrong] = hl; cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0)); + // Semantic roles stay independent of the accent: a generated palette must + // still paint add/ready green and remove/not-ready red. + cfg.appColors[AppColor::Positive] = defaultAppColor(AppColor::Positive, dark); + cfg.appColors[AppColor::Negative] = defaultAppColor(AppColor::Negative, dark); // BrightText QColor bright; diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 392f01d0d..3fec389da 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -60,8 +61,14 @@ static const QMap ROLE_DESCRIPTIONS = { }; static const QMap APP_ROLE_DESCRIPTIONS = { - {AppColor::AccentStrong, QT_TR_NOOP("Vivid primary accent (e.g. home-tab button gradient start)")}, - {AppColor::AccentSoft, QT_TR_NOOP("Lightened, desaturated accent (e.g. home-tab button gradient end)")}, + {AppColor::AccentStrong, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Vivid primary accent (e.g. home-tab button gradient start)")}, + {AppColor::AccentSoft, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Lightened, desaturated accent (e.g. home-tab button gradient end)")}, + {AppColor::Positive, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state and additive actions (ready, add, increment)")}, + {AppColor::Negative, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state and removal actions (not ready, remove, decrement)")}, }; PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) @@ -179,7 +186,7 @@ void PaletteGridWidget::buildGrid(QWidget *host) } auto *label = new QLabel(QString(appEnum.valueToKey(role)), host); - label->setToolTip(APP_ROLE_DESCRIPTIONS.value(role, {})); + label->setToolTip(QCoreApplication::translate("PaletteGridWidget", APP_ROLE_DESCRIPTIONS.value(role, {}))); label->setContentsMargins(4, 2, 8, 2); grid->addWidget(label, row, 0); diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 54143d7d3..42d7a4691 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -451,3 +452,19 @@ FontConfig FontConfig::fromThemeDir(const QString &themeDirPath) return fromFile(QDir(themeDirPath).absoluteFilePath(fileName())); } + +QColor defaultAppColor(AppColor::Role role, bool dark) +{ + switch (role) { + case AppColor::AccentStrong: + case AppColor::AccentSoft: + // Derived from the palette highlight by ThemeManager::appColor(). + return {}; + case AppColor::Positive: + return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1b7f3b")); + case AppColor::Negative: + return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e")); + } + + return {}; +} diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 84601a2bf..7d05e4bf0 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -20,6 +20,8 @@ enum Role { AccentStrong, AccentSoft, + Positive, + Negative, }; Q_ENUM_NS(Role) } // namespace AppColor @@ -41,6 +43,16 @@ enum Role Q_ENUM_NS(Role) } // namespace AppFont +/** + * @brief Fallback for an application color role when the active theme does not + * define one. + * + * Accent roles return an invalid QColor because ThemeManager derives them from + * the palette highlight. Semantic roles return an opaque default that suits the + * scheme, so themes written before the role existed still render it. + */ +QColor defaultAppColor(AppColor::Role role, bool dark); + struct ThemeConfig { QString colorScheme; diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index 161c436ca..622534d8c 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -1,6 +1,9 @@ #include "theme_icon.h" #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 @@ -27,17 +30,6 @@ namespace { -/** - * @brief Palette color that replaces `currentColor`, following the icon mode. - */ -QByteArray sentinelColor(QIcon::Mode mode, QPalette::ColorRole role) -{ - const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; - const QPalette::ColorRole effective = - mode == QIcon::Selected && role == QPalette::WindowText ? QPalette::HighlightedText : role; - return qApp->palette().color(group, effective).name(QColor::HexRgb).toLatin1(); -} - /** * @brief Reads an SVG through the `theme:` search path. */ @@ -99,14 +91,19 @@ QString cacheKey(const QString &path, const QByteArray &variant, const QSize &si * and the intrinsic size are cached until the theme changes: a repaint then * does no file I/O at all. The render cache is keyed by resolved path and pixel * size plus a variant: the palette color for sentinel SVGs, the icon mode for - * assets that carry their own colors. A palette change therefore produces new - * keys instead of reusing art built under the previous palette, and the - * non-sentinel path (raster overrides, plain SVGs) is cached like any other. + * assets that carry their own colors, and the tone plus glyph color for a badge. + * A palette change therefore produces new keys instead of reusing art built + * under the previous palette, and the non-sentinel path (raster overrides, plain + * SVGs) is cached like any other. + * + * The engine only resolves and caches. The badge shape itself is painted by + * ThemeIconBadge so the compositing stays out of the resolution path. */ class ThemeSvgIconEngine final : public QObject, public QIconEngine { public: - ThemeSvgIconEngine(QString _prefix, QPalette::ColorRole _role) : prefix(std::move(_prefix)), role(_role) + ThemeSvgIconEngine(QString _prefix, IconTint _tint, IconStyle _style) + : prefix(std::move(_prefix)), tint(_tint), style(_style) { if (themeManager) { // A theme switch changes the resolved asset (scheme-qualified @@ -120,7 +117,7 @@ public: QIconEngine *clone() const override { - return new ThemeSvgIconEngine(prefix, role); + return new ThemeSvgIconEngine(prefix, tint, style); } void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State) override @@ -220,9 +217,21 @@ private: QPixmap fromAsset(const QSize &pixelSize, QIcon::Mode mode) const { - QPixmap pixmap(QStringLiteral("theme:") + resolvedPath); - if (!pixelSize.isEmpty() && pixmap.size() != pixelSize) { - pixmap = pixmap.scaled(pixelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + QPixmap pixmap; + if (resolvedAssetIsSvg) { + // Render authored SVG art through QSvgRenderer rather than QPixmap. + // Loading an `.svg` through QPixmap needs Qt's SVG *image-format* + // plugin, which is a separate package on Linux distributions and is + // absent from minimal installs and from CI (it is a Recommends of + // qt6-svg-dev, so `--no-install-recommends` drops it). Art a theme + // ships must not disappear because of a packaging choice, and the + // renderer is already linked for the sentinel path. + pixmap = renderSvg(resolvedSvg, pixelSize.isEmpty() ? intrinsicSize() : pixelSize, 1.0); + } else { + pixmap = QPixmap(QStringLiteral("theme:") + resolvedPath); + if (!pixelSize.isEmpty() && pixmap.size() != pixelSize) { + pixmap = pixmap.scaled(pixelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } } if (mode == QIcon::Disabled && !pixmap.isNull()) { pixmap = QApplication::style()->generatedIconPixmap(QIcon::Disabled, pixmap, nullptr); @@ -234,11 +243,35 @@ private: { ensureResolved(); - // Same cache for both paths: sentinel art varies by palette color, art + // 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 two key spaces cannot collide. - const QByteArray variant = - resolvedHasSentinel ? sentinelColor(mode, role) : QByteArray::number(static_cast(mode)); + // variant is never a bare digit, so the key spaces cannot collide. A + // badge also varies by glyph color, because the same tone can switch + // between a white and a near-black glyph at different lightnesses. + // The selected-row contrast fallback exists for a thin glyph painted + // directly on the highlight. A badge or sticker brings its own ring and + // contrast glyph, so repainting its whole tone with HighlightedText + // would collapse every badge on a selected row into one gray disc. + const QColor tone = resolvedHasSentinel ? tint.resolve(treated ? QIcon::Normal : mode) : QColor(); + QByteArray variant; + 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 { + variant = QByteArray::number(static_cast(mode)); + } const QString key = cacheKey(resolvedPath, variant, pixelSize, devicePixelRatio); const auto cached = cache.constFind(key); if (cached != cache.constEnd()) { @@ -246,7 +279,13 @@ private: } QPixmap pixmap; - if (resolvedHasSentinel) { + if (badge) { + // Geometry is computed from the device-pixel size, and the ratio is + // attached below like every other path. + pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize); + } else if (sticker) { + pixmap = ThemeIconSticker::renderSticker(resolvedSvg, tone, pixelSize); + } else if (resolvedHasSentinel) { pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, variant), pixelSize, devicePixelRatio); } else { // Authored art carries its own colors: use Qt's normal loader so @@ -266,7 +305,8 @@ private: } QString prefix; - QPalette::ColorRole role; + IconTint tint; + IconStyle style; mutable QHash cache; mutable QString resolvedPath; mutable QString resolvedThemePath; @@ -279,12 +319,51 @@ private: }; } // namespace -QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role) +IconTint::IconTint(QPalette::ColorRole role) : paletteRole(role) { - return QIcon(new ThemeSvgIconEngine(prefix.toString(), role)); } -QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole role, const QSize &size) +IconTint::IconTint(AppColor::Role role) : source(Source::AppColor), appRole(role) +{ +} + +QColor IconTint::resolve(QIcon::Mode mode) const +{ + if (source == Source::AppColor) { + // A disabled control must not keep signalling its semantic color. + if (mode == QIcon::Disabled) { + return qApp->palette().color(QPalette::Disabled, QPalette::WindowText); + } + // 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; + } + // Selected rows paint their icon on the highlight colour. Keep the + // semantic tint when it stays legible there; otherwise the glyph would + // vanish into the selection. + constexpr double MIN_SELECTED_CONTRAST = 3.0; + const QColor highlight = qApp->palette().color(QPalette::Active, QPalette::Highlight); + return ThemeIconBadge::contrastRatio(color, highlight) >= MIN_SELECTED_CONTRAST + ? color + : qApp->palette().color(QPalette::Active, QPalette::HighlightedText); + } + + const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; + const QPalette::ColorRole effective = + mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole; + return qApp->palette().color(group, effective); +} + +QIcon loadThemedIcon(QStringView prefix, IconTint tint, IconStyle style) +{ + return QIcon(new ThemeSvgIconEngine(prefix.toString(), tint, style)); +} + +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const QSize &size, IconStyle style) { if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) { return {}; @@ -295,6 +374,15 @@ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole r return {}; } - const QByteArray color = qApp->palette().color(role).name(QColor::HexRgb).toLatin1(); - return renderSvg(resolveSvgColorSentinel(svg, color), size, 1.0); + const QColor tone = tint.resolve(QIcon::Normal); + if (style == IconStyle::Badge || style == IconStyle::Sticker) { + QSize canvas = size; + if (canvas.isEmpty()) { + canvas = QSvgRenderer(svg).defaultSize(); + } + 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 e26015ea1..e1a867fc0 100644 --- a/cockatrice/src/interface/theme_icon.h +++ b/cockatrice/src/interface/theme_icon.h @@ -6,6 +6,9 @@ #ifndef THEME_ICON_H #define THEME_ICON_H +#include "theme_config.h" + +#include #include #include #include @@ -13,6 +16,73 @@ #include #include +/** + * @brief Selects how a `currentColor` glyph is painted. + * + * The tint decides the tone. The style decides how much shape carries it. + * + * Line is the default and matches what every caller did before this axis + * existed: the recolored glyph alone, at the author's stroke weight. Use it for + * navigation, menu and toolbar glyphs, where a row of badges would compete with + * the content. + * + * Badge reproduces the pre-#7595 ready/not-ready art: a filled disc, a dark ring + * and a contrast glyph derived from the tone. A filled shape draws more + * attention than a line, so it is opt-in per call site. It was made for the + * ready/not-ready icons. The add/remove/delete icons were filled glyphs with an + * outline and no disc, so badging them would produce art the UI never had. + * Authored art that carries its own colors is never badged and falls back to the + * asset path. Disabled icons fall back to the line glyph too, 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 + Sticker, ///< filled glyph + dark outline, no disc +}; + +/** + * @brief Selects the color that replaces the `currentColor` sentinel. + * + * A palette role follows Qt's structural palette. An application color carries + * meaning that QPalette cannot express, such as positive and negative states. + * Callers choose the role. The active theme supplies the value. + */ +class IconTint +{ +public: + IconTint() = default; + IconTint(QPalette::ColorRole role); // implicit: existing palette-role callers + IconTint(AppColor::Role role); // implicit: semantic callers + + /** + * @brief Resolves the concrete color for an icon mode. + * + * A disabled application color resolves to the disabled foreground, so an + * unavailable control does not keep its semantic color. A selected palette + * color keeps the WindowText -> HighlightedText swap. Application colors go + * through the saturation floor shared by the filled treatments, so a line + * glyph, a badge disc and a sticker fill carry the same tone. + */ + QColor resolve(QIcon::Mode mode) const; + +private: + enum class Source + { + Palette, + AppColor, + }; + + Source source = Source::Palette; + QPalette::ColorRole paletteRole = QPalette::WindowText; + AppColor::Role appRole = AppColor::AccentStrong; +}; + /** * @brief Builds a palette-aware icon from a `theme:` asset. * @@ -24,11 +94,12 @@ * * @param prefix Asset prefix relative to the `theme:` search path, with no file * extension (for example `icons/search`), matching themePixmap(). - * @param role Palette role whose color replaces the sentinel. Selected glyphs - * use HighlightedText unless a role other than WindowText is given. + * @param tint Color that replaces the sentinel: a palette role or a semantic + * application color. Defaults to WindowText. + * @param style Treatment of the glyph: Line, Badge or Sticker. Defaults to Line. * @return An icon that follows the application palette. */ -QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::WindowText); +QIcon loadThemedIcon(QStringView prefix, IconTint tint = {}, IconStyle style = IconStyle::Line); /** * @brief Renders a resolved theme SVG when it carries the `currentColor` sentinel. @@ -38,13 +109,17 @@ QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::Wi * * @param resolvedPath Asset path relative to the `theme:` search path, as * returned by ThemeManager::assetPath (includes extension). - * @param role Palette role whose color replaces the sentinel. + * @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: Line, Badge or Sticker. 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. */ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, - QPalette::ColorRole role = QPalette::WindowText, - const QSize &size = {}); + IconTint tint = {}, + const QSize &size = {}, + IconStyle style = IconStyle::Line); #endif // THEME_ICON_H diff --git a/cockatrice/src/interface/theme_icon_badge.cpp b/cockatrice/src/interface/theme_icon_badge.cpp new file mode 100644 index 000000000..e9ec057f7 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_badge.cpp @@ -0,0 +1,137 @@ +#include "theme_icon_badge.h" + +#include "libcockatrice/utility/svg_recolor.h" +#include "theme_icon_fill.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// The pre-#7595 badge art filled almost the whole canvas: a 24px icon carried a +// 23px disc with a hairline black outline. Keep the disc large and the ring +// thin so the glyph still has room at 16px. +constexpr double RING_WIDTH_RATIO = 0.04; +// The sentinel paths sit inside a padded 24-unit viewBox (the check spans 4..20), +// so the glyph box has to be nearly the whole disc for the engraved mark to +// match the old art's weight. At half the disc it read as a smudge at 15px. +constexpr double GLYPH_DIAMETER_RATIO = 0.9; +// The sentinel set is drawn at 2 units in a 24-unit viewBox, which turns into a +// hairline once the glyph is scaled into a 15px disc. The old badge carried a +// band near a tenth of the disc diameter, so the badge decides the glyph weight +// instead of inheriting the authored one. +constexpr qreal GLYPH_STROKE_WIDTH = 3.0; +constexpr int RING_DARKER = 300; +constexpr int GLYPH_DARKER = 400; +} // namespace + +namespace +{ +struct DiscFill +{ + QColor top; + QColor bottom; +}; + +DiscFill discFill(const QColor &tone) +{ + return {ThemeIconFill::topColor(tone), ThemeIconFill::bottomColor(tone)}; +} +} // namespace + +namespace ThemeIconBadge +{ +double relativeLuminance(const QColor &color) +{ + const auto linear = [](double channel) { + channel /= 255.0; + return channel <= 0.03928 ? channel / 12.92 : std::pow((channel + 0.055) / 1.055, 2.4); + }; + return 0.2126 * linear(color.red()) + 0.7152 * linear(color.green()) + 0.0722 * linear(color.blue()); +} + +double contrastRatio(const QColor &first, const QColor &second) +{ + const double a = relativeLuminance(first); + const double b = relativeLuminance(second); + return (qMax(a, b) + 0.05) / (qMin(a, b) + 0.05); +} + +QColor glyphColor(const QColor &tone) +{ + // 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) +{ + if (pixelSize.isEmpty()) { + return {}; + } + const qreal side = qMin(pixelSize.width(), pixelSize.height()); + const qreal ringWidth = qMax(1.0, side * RING_WIDTH_RATIO); + const qreal discDiameter = side - 2.0 * ringWidth; + const int glyphSide = qMax(1, qRound(discDiameter * GLYPH_DIAMETER_RATIO)); + return QSize(glyphSide, glyphSide); +} + +QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize) +{ + if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) { + return {}; + } + + QPixmap pixmap(pixelSize); + pixmap.fill(Qt::transparent); + + const qreal side = qMin(pixelSize.width(), pixelSize.height()); + const qreal ringWidth = qMax(1.0, side * RING_WIDTH_RATIO); + const qreal discDiameter = side - 2.0 * ringWidth; + + QRectF discRect(0, 0, discDiameter, discDiameter); + discRect.moveCenter(QRectF(QPointF(0, 0), QSizeF(pixelSize)).center()); + + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + + QLinearGradient gradient(discRect.topLeft(), discRect.bottomLeft()); + 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); + + // 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(ThemeIconFill::saturated(tone).darker(RING_DARKER), ringWidth)); + const qreal ringRadius = (side - ringWidth) / 2.0; + painter.drawEllipse(discRect.center(), ringRadius, ringRadius); + + 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); + const QRectF glyphRect( + QPointF(discRect.center().x() - size.width() / 2.0, discRect.center().y() - size.height() / 2.0), + QSizeF(size)); + renderer.render(&painter, glyphRect); + } + + return pixmap; +} +} // namespace ThemeIconBadge diff --git a/cockatrice/src/interface/theme_icon_badge.h b/cockatrice/src/interface/theme_icon_badge.h new file mode 100644 index 000000000..cdb43cbd9 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_badge.h @@ -0,0 +1,81 @@ +/** + * @file theme_icon_badge.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_BADGE_H +#define THEME_ICON_BADGE_H + +#include +#include +#include +#include + +/** + * @brief Paints the badge treatment of a `currentColor` glyph. + * + * A tint alone changes the color of a 2px line, but the UI drew ready and + * not-ready states as a filled badge before the one-glyph cutover. The badge + * treatment keeps that shape: a filled disc, a dark ring and a contrast glyph, + * all derived from the tone the tint resolves to. The theme still owns the + * color. Callers only choose between a line and a badge. + * + * A badge survives a busy toolbar and a tinted row better than a hairline, and + * its glyph can be light or dark regardless of the surrounding text. The cost is + * more attention per control, and the glyph color follows the scheme it is drawn + * for instead of the window's text color. + * + * Everything here works in device pixels. The icon engine renders at the + * device-pixel size it already computed and sets the pixmap's device pixel ratio + * afterwards, so badge geometry must not assume logical pixels. + */ +namespace ThemeIconBadge +{ +/** + * @brief WCAG relative luminance, used to test a color against a background. + * + * Used by IconTint::resolve()'s selected-row contrast test. + */ +double relativeLuminance(const QColor &color); + +/** @brief WCAG contrast ratio between two opaque colors, from 1.0 to 21.0. */ +double contrastRatio(const QColor &first, const QColor &second); + +/** + * @brief Glyph color for a badge disc. + * + * 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); + +/** @brief Size of the engraved glyph for a badge of @p pixelSize, in device pixels. */ +QSize glyphSize(const QSize &pixelSize); + +/** + * @brief Renders a sentinel-carrying SVG as a badge. + * + * The disc fills the square canvas minus the ring 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 + * old add/remove icons were filled glyphs with an outline and no disc, so + * badging them would invent a look the UI never had. + * + * @param svg Sentinel-carrying SVG source; `currentColor` is resolved internally. + * @param tone Disc color, already resolved for the icon mode. + * @param pixelSize Canvas size in device pixels; a non-square canvas centers + * the badge and leaves the remaining area transparent. + * @return The composited badge, or a null pixmap for empty or invalid input. + */ +QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize); +} // namespace ThemeIconBadge + +#endif // THEME_ICON_BADGE_H diff --git a/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..4efcc136e --- /dev/null +++ b/cockatrice/src/interface/theme_icon_fill.h @@ -0,0 +1,63 @@ +/** + * @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. The + * semantic tones are tuned for text, not for filling a whole shape; the + * dark-scheme tones in particular are desaturated and read washed out once a + * disc or a glyph carries them. The badge and the sticker share one fill rule so + * they sit next to each other with the same green or red. + * + * The rule needs no scheme switch: a tone whose value is already high is a + * dark-scheme tone, so its gradient is pulled down. 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..d9e5ec3ba --- /dev/null +++ b/cockatrice/src/interface/theme_icon_sticker.h @@ -0,0 +1,61 @@ +/** + * @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 and no disc. A tint alone cannot reproduce that: it produces a + * hairline, and a badge disc is 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. + * + * A sticker is louder than a line, which suits an add or a destructive action. + * 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/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index f120af8e4..1cf62ae8b 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -639,14 +639,23 @@ QColor ThemeManager::appColor(AppColor::Role role) const // unconditionally keeps every Qt version rendering identically. const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight); - if (role == AppColor::AccentSoft) { - constexpr int SOFT_SATURATION_PERCENT = 70; - constexpr int SOFT_LIGHTNESS_OFFSET = 60; + switch (role) { + case AppColor::AccentStrong: + return accent; + case AppColor::AccentSoft: { + constexpr int SOFT_SATURATION_PERCENT = 70; + constexpr int SOFT_LIGHTNESS_OFFSET = 60; - // Light end of the gradient: same hue, softened and lightened - return QColor::fromHsl(qMax(0, accent.hslHue()), - qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255), - qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255)); + // Light end of the gradient: same hue, softened and lightened + return QColor::fromHsl(qMax(0, accent.hslHue()), + qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255), + qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255)); + } + case AppColor::Positive: + case AppColor::Negative: + // Semantic colors are independent of the accent: a missing role must + // not silently paint "remove" in the theme's highlight color. + return defaultAppColor(role, isDarkModeActive()); } return accent; diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index ffe1043b5..3bcfd7b3c 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -147,7 +147,8 @@ public: void previewFonts(const FontConfig &cfg); // Resolves an application color role: the theme's stored [AppColors] value - // when present, otherwise a palette-accent-derived fallback. + // when present, otherwise a palette-accent-derived fallback for accents and + // a scheme-appropriate semantic default for positive / negative. QColor appColor(AppColor::Role role) const; /** @brief Resolves an application font role: the theme's stored [Fonts] * value when present, otherwise the application font. Roles that diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index f25e6c0ff..128597962 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp @@ -83,12 +83,14 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent &DeckEditorDatabaseDisplayWidget::onRelatedCardClicked); aAddCard = new QAction(QString(), this); - aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"))); + aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"), AppColor::Positive)); connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck); auto *tbAddCard = new QToolButton(this); tbAddCard->setDefaultAction(aAddCard); aAddCardToSideboard = new QAction(QString(), this); + // Sideboard is a destination, not a positive state: its distinct glyph + // keeps the target legible without competing with the green main-deck add. aAddCardToSideboard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right_sideboard"))); connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard); auto *tbAddCardToSideboard = new QToolButton(this); 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 47d2a1aae..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"))); + 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"))); + 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"))); + 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 8122d1847..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"))); + 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 daee5f1f9..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"))); + 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 b06481c4b..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"))); + 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"))); + 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 09afee32d..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"))); + 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 e90603858..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"))); + 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"))); + 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 274e55866..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"))); + 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"))); + 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 8af9d5e6e..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"))); + 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"))); + 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 bd2a69017..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"))); + 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"))); + 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/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 6d9b28619..7f516839a 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -156,7 +156,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, clearFilterWidget = new QToolButton(); clearFilterWidget->setFixedSize(32, 32); - clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"))); + clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"), AppColor::Negative)); connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml index cad18921a..5a961bd5d 100644 --- a/cockatrice/themes/Fabric/palette-default-dark.toml +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -71,4 +71,6 @@ Accent = #4a82e6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml index f5151af3e..a958b39d1 100644 --- a/cockatrice/themes/Fabric/palette-default-light.toml +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -71,4 +71,6 @@ Accent = #2f6fd6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 +Positive = #1b7f3b +Negative = #b3261e diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index b180e9b63..8be52dc5f 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -71,3 +71,5 @@ Accent = #ff1e1e1e [AppColors] AccentStrong = #ff139740 AccentSoft = #ffc9fd62 +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 5a1163a2b..9548af916 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -71,3 +71,5 @@ Accent = #fff0f0f0 [AppColors] AccentStrong = #ff139740 AccentSoft = #ffc9fd62 +Positive = #1b7f3b +Negative = #b3261e diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml index b0db0e90a..26bee5be3 100644 --- a/cockatrice/themes/Leather/palette-default-dark.toml +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -71,4 +71,6 @@ Accent = #c9995a [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml index ff122db76..0219fce8e 100644 --- a/cockatrice/themes/Leather/palette-default-light.toml +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -71,4 +71,6 @@ Accent = #a5712f [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f +Positive = #1b7f3b +Negative = #b3261e diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml index 222456f4f..e013e3602 100644 --- a/cockatrice/themes/Plasma/palette-default-dark.toml +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -71,4 +71,6 @@ Accent = #6a4df0 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml index d54db31d2..71d86917c 100644 --- a/cockatrice/themes/Plasma/palette-default-light.toml +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -71,4 +71,6 @@ Accent = #5a3ee2 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 +Positive = #1b7f3b +Negative = #b3261e diff --git a/cockatrice/themes/System/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml index 9dfe6e19f..9f945fa30 100644 --- a/cockatrice/themes/System/palette-default-dark.toml +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -1,7 +1,10 @@ # The System theme derives all of its colors from the platform palette # (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette -# overrides. Only the identity app colors are provided here, so buttons keep -# their classic greens while the actual palette stays native to the OS. +# overrides. Only the identity and semantic app colors are provided here, so +# buttons keep their classic greens while the actual palette stays native to +# the OS. [AppColors] AccentStrong = #ff148c3c AccentSoft = #ff78c850 +Positive = #ff6dd58c +Negative = #ffff8a80 diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml index 9dfe6e19f..32cf7845c 100644 --- a/cockatrice/themes/System/palette-default-light.toml +++ b/cockatrice/themes/System/palette-default-light.toml @@ -1,7 +1,10 @@ # The System theme derives all of its colors from the platform palette # (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette -# overrides. Only the identity app colors are provided here, so buttons keep -# their classic greens while the actual palette stays native to the OS. +# overrides. Only the identity and semantic app colors are provided here, so +# buttons keep their classic greens while the actual palette stays native to +# the OS. [AppColors] AccentStrong = #ff148c3c AccentSoft = #ff78c850 +Positive = #ff1b7f3b +Negative = #ffb3261e diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml index 496943931..56d3e2c4c 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-dark.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -71,4 +71,6 @@ Accent = #8fa6c0 [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml index 5323fd320..04461013b 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-light.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -71,4 +71,6 @@ Accent = #54687e [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 +Positive = #1b7f3b +Negative = #b3261e 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 4ca2acd07..50799d496 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -32,6 +32,9 @@ set(oracle_SOURCES ../cockatrice/src/interface/pixel_map_generator.cpp ../cockatrice/src/interface/theme_config.cpp ../cockatrice/src/interface/theme_icon.cpp + ../cockatrice/src/interface/theme_icon_badge.cpp + ../cockatrice/src/interface/theme_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 818c1bb16..d0bd357b3 100644 --- a/tests/theme_migration/CMakeLists.txt +++ b/tests/theme_migration/CMakeLists.txt @@ -6,6 +6,9 @@ cockatrice_add_test( ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/pixel_map_generator.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_badge.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/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 @@ -13,6 +16,8 @@ cockatrice_add_test( ${VERSION_STRING_CPP} theme_migration_test.cpp theme_icon_render_test.cpp + icon_tint_preview_test.cpp + icon_treatment_preview_test.cpp LIBS libcockatrice_settings libcockatrice_network diff --git a/tests/theme_migration/icon_tint_preview_test.cpp b/tests/theme_migration/icon_tint_preview_test.cpp new file mode 100644 index 000000000..b4244cef0 --- /dev/null +++ b/tests/theme_migration/icon_tint_preview_test.cpp @@ -0,0 +1,227 @@ +#include "interface/theme_config.h" +#include "libcockatrice/utility/svg_recolor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Creates the GUI application QPixmap needs, using the offscreen plugin. + * + * The theme-migration binary is headless; creating this lazily keeps the other + * test files free of a display dependency. + */ +void ensureGuiApplication() +{ + static int argc = 1; + static char name[] = "icon_tint_preview_test"; + static char *argv[] = {name, nullptr}; + // Leaked on purpose: a function-local static QApplication is destroyed before Qt's + // own library statics at exit, and the network module's destructors then run + // against a torn-down QObject layer (SEGV under AddressSanitizer). + static QApplication *app = new QApplication(argc, argv); + Q_UNUSED(app); +} + +QString resourcesDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources")); +} + +/** @brief Reads an authored icon from the theme resource directory. */ +QByteArray readIconSvg(const char *name) +{ + QFile file(QDir(resourcesDir()).absoluteFilePath(QStringLiteral("icons/%1.svg").arg(QString::fromLatin1(name)))); + if (!file.open(QFile::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +/** @brief Renders a pixmap at device size and tags it with the ratio. */ +template QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render) +{ + QPixmap pixmap = render(logicalSize * dpr); + pixmap.setDevicePixelRatio(dpr); + return pixmap; +} +} // namespace + +/** + * @brief Writes one semantic-tint sheet per shipped palette theme. + * + * The theme files are the only place the shipped tones live, so each sheet is + * rendered from the palette the theme actually ships: both semantic families, + * three glyphs each, at the two sizes the UI uses, on the window and button + * surfaces the contrast is measured against. A disabled row closes each family + * so the enabled/disabled order is visible next to the tone that carries it. + */ +TEST(IconTintPreview, WritesOneSheetPerShippedTheme) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ensureGuiApplication(); + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 190; + constexpr int SLOT = 36; + constexpr int ROW_HEIGHT = 56; + constexpr int PANEL_TITLE = 26; + constexpr int PANEL_PADDING = 12; + constexpr qreal DPR = 2.0; + constexpr int SIZES[] = {16, 24}; + + struct Family + { + const char *name; + AppColor::Role role; + const char *glyphs[3]; + }; + const QList families = { + {"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}}, + {"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}}, + }; + const QStringList themes = { + QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), + QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), + }; + + const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + const int glyphCount = static_cast(std::size(families.first().glyphs)); + const int columnCount = glyphCount * static_cast(std::size(SIZES)); + const int panelWidth = LABEL_WIDTH + columnCount * SLOT + PANEL_PADDING; + const int panelHeight = PANEL_TITLE + static_cast(families.size()) * 2 * ROW_HEIGHT + PANEL_PADDING; + const int logicalWidth = 2 * MARGIN + panelWidth; + const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; + + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + ASSERT_TRUE(config.hasPalette()) << theme.toStdString(); + + const QPalette palette = config.apply(QPalette()); + const QColor window = palette.color(QPalette::Active, QPalette::Window); + const QColor button = palette.color(QPalette::Active, QPalette::Button); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + const QColor disabled = palette.color(QPalette::Disabled, QPalette::WindowText); + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + 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"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, semantic tints") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + struct Surface + { + const char *name; + QColor background; + }; + const QList surfaces = { + {"Window surface", window}, + {"Button surface", button}, + }; + + qreal panelTop = MARGIN + 30; + for (const Surface &surface : surfaces) { + painter.setPen(Qt::NoPen); + painter.setBrush(surface.background); + painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8); + + const qreal contentLeft = MARGIN + PANEL_PADDING; + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name)); + + painter.setFont(QFont(QStringLiteral("Sans"), 8)); + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const qreal x = contentLeft + LABEL_WIDTH + static_cast(sizeIndex) * glyphCount * SLOT; + painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14), + QStringLiteral("%1 px").arg(SIZES[sizeIndex])); + } + + qreal y = panelTop + PANEL_TITLE + 20; + for (const Family &family : families) { + const QColor tone = config.appColors.value(family.role, defaultAppColor(family.role, dark)); + ASSERT_TRUE(tone.isValid()) << theme.toStdString() << " " << family.name; + for (int state = 0; state < 2; ++state) { + const bool isDisabled = state == 1; + const QColor ink = isDisabled ? disabled : tone; + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText( + QPointF(contentLeft, y + 14), + QStringLiteral("%1%2").arg(QString::fromLatin1(family.name), + isDisabled ? QStringLiteral(" / disabled") : QString())); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const int side = SIZES[sizeIndex]; + const QSize size(side, side); + for (int glyphIndex = 0; glyphIndex < glyphCount; ++glyphIndex) { + const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); + ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; + const QPixmap pixmap = renderAtDpr( + size, DPR, [&](const QSize &pixelSize) { return renderLine(svg, ink, pixelSize); }); + const qreal x = contentLeft + LABEL_WIDTH + + static_cast(sizeIndex * glyphCount + glyphIndex) * SLOT + + (SLOT - side) / 2.0; + painter.drawPixmap(QPointF(x, y + (ROW_HEIGHT - side) / 2.0 - 2), pixmap); + } + } + y += ROW_HEIGHT; + } + } + panelTop += panelHeight + 24; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("semantic-tint-preview-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } + } +} diff --git a/tests/theme_migration/icon_treatment_preview_test.cpp b/tests/theme_migration/icon_treatment_preview_test.cpp new file mode 100644 index 000000000..212987ec0 --- /dev/null +++ b/tests/theme_migration/icon_treatment_preview_test.cpp @@ -0,0 +1,245 @@ +#include "interface/theme_config.h" +#include "interface/theme_icon_badge.h" +#include "interface/theme_icon_fill.h" +#include "interface/theme_icon_sticker.h" +#include "libcockatrice/utility/svg_recolor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Creates the GUI application QPixmap needs, using the offscreen plugin. + * + * The theme-migration binary is headless; creating this lazily keeps the other + * test files free of a display dependency. + */ +void ensureGuiApplication() +{ + static int argc = 1; + static char name[] = "icon_treatment_preview_test"; + static char *argv[] = {name, nullptr}; + // Leaked on purpose: a function-local static QApplication is destroyed before Qt's + // own library statics at exit, and the network module's destructors then run + // against a torn-down QObject layer (SEGV under AddressSanitizer). + static QApplication *app = new QApplication(argc, argv); + Q_UNUSED(app); +} + +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(QFile::ReadOnly) ? file.readAll() : QByteArray(); +} + +/** @brief Renders a pixmap at device size and tags it with the ratio. */ +template QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render) +{ + QPixmap pixmap = render(logicalSize * dpr); + pixmap.setDevicePixelRatio(dpr); + return pixmap; +} +} // namespace + +/** + * @brief Writes one badge/sticker sheet per shipped palette theme. + * + * The treatments are only useful if they read on every palette, so each sheet + * is rendered from the palette the theme actually ships: both semantic + * families, three glyphs each, as line glyphs, stickers and badges, at the two + * sizes the UI uses, on the window and button surfaces the contrast is measured + * against. A disabled row closes each family: every treatment falls back to the + * line glyph there, so an unavailable control still reads as unavailable. + */ +TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ensureGuiApplication(); + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 190; + constexpr int SLOT = 36; + constexpr int TREATMENT_WIDTH = 120; + constexpr int ROW_HEIGHT = 56; + constexpr int PANEL_TITLE = 26; + constexpr int COLUMN_HEADER = 20; + constexpr int PANEL_PADDING = 12; + constexpr qreal DPR = 2.0; + constexpr int SIZES[] = {16, 24}; + constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"}; + + struct Family + { + const char *name; + AppColor::Role role; + const char *glyphs[3]; + }; + const QList families = { + {"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}}, + {"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}}, + }; + const QStringList themes = { + QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), + QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), + }; + + const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + const int treatmentCount = static_cast(std::size(TREATMENTS)); + const int panelWidth = LABEL_WIDTH + treatmentCount * TREATMENT_WIDTH + PANEL_PADDING; + const int panelHeight = + PANEL_TITLE + COLUMN_HEADER + static_cast(families.size()) * 2 * ROW_HEIGHT + PANEL_PADDING; + const int logicalWidth = 2 * MARGIN + panelWidth; + const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; + + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + ASSERT_TRUE(config.hasPalette()) << theme.toStdString(); + + const QPalette palette = config.apply(QPalette()); + const QColor window = palette.color(QPalette::Active, QPalette::Window); + const QColor button = palette.color(QPalette::Active, QPalette::Button); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + const QColor disabled = palette.color(QPalette::Disabled, QPalette::WindowText); + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + 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"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, treatments across glyphs") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + struct Surface + { + const char *name; + QColor background; + }; + const QList surfaces = { + {"Window surface", window}, + {"Button surface", button}, + }; + + qreal panelTop = MARGIN + 30; + for (const Surface &surface : surfaces) { + painter.setPen(Qt::NoPen); + painter.setBrush(surface.background); + painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8); + + const qreal contentLeft = MARGIN + PANEL_PADDING; + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name)); + + painter.setFont(QFont(QStringLiteral("Sans"), 8)); + for (int treatment = 0; treatment < treatmentCount; ++treatment) { + const qreal x = contentLeft + LABEL_WIDTH + static_cast(treatment) * TREATMENT_WIDTH; + painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14), + QString::fromLatin1(TREATMENTS[treatment]) + QStringLiteral(" 16 / 24")); + } + + qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER; + for (const Family &family : families) { + const QColor raw = config.appColors.value(family.role, defaultAppColor(family.role, dark)); + ASSERT_TRUE(raw.isValid()) << theme.toStdString() << " " << family.name; + const QColor tone = ThemeIconFill::saturated(raw); + + for (int state = 0; state < 2; ++state) { + const bool isDisabled = state == 1; + const QColor ink = isDisabled ? disabled : tone; + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText( + QPointF(contentLeft, y + 14), + QStringLiteral("%1%2").arg(QString::fromLatin1(family.name), + isDisabled ? QStringLiteral(" / disabled") : QString())); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + + for (int treatment = 0; treatment < treatmentCount; ++treatment) { + const qreal cellX = + contentLeft + LABEL_WIDTH + static_cast(treatment) * TREATMENT_WIDTH; + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const int side = SIZES[sizeIndex]; + const QSize size(side, side); + const qreal rowTop = y + (sizeIndex == 0 ? 4 : 28); + for (int glyphIndex = 0; glyphIndex < 3; ++glyphIndex) { + const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); + ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; + const QPixmap pixmap = renderAtDpr(size, DPR, [&](const QSize &pixelSize) { + if (isDisabled || treatment == 0) { + return renderLine(svg, ink, pixelSize); + } + if (treatment == 1) { + return ThemeIconSticker::renderSticker(svg, ink, pixelSize); + } + return ThemeIconBadge::renderBadge(svg, ink, pixelSize); + }); + const qreal x = + cellX + 6 + static_cast(glyphIndex) * SLOT + (SLOT - side) / 2.0; + painter.drawPixmap(QPointF(x, rowTop + (SLOT - side) / 2.0), pixmap); + } + } + } + y += ROW_HEIGHT; + } + } + panelTop += panelHeight + 24; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("treatment-preview-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } + } +} diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index f12db5b16..ee078555d 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -1,19 +1,30 @@ #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" #include +#include #include +#include #include #include +#include #include #include +#include #include #include +#include #include #include #include +#include #include #include +#include namespace { @@ -40,6 +51,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(); @@ -56,6 +75,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"(palette(); + const QColor accent(QStringLiteral("#ff00ff")); + palette.setColor(QPalette::Active, QPalette::Highlight, accent); + qApp->setPalette(palette); + + const QColor positive = themeManager->appColor(AppColor::Positive); + const QColor negative = themeManager->appColor(AppColor::Negative); + ASSERT_TRUE(positive.isValid()); + ASSERT_TRUE(negative.isValid()); + EXPECT_NE(positive, accent); + EXPECT_NE(negative, accent); + + const QPixmap positivePixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg"), AppColor::Positive); + ASSERT_FALSE(positivePixmap.isNull()); + EXPECT_GT(countOpaquePixels(positivePixmap, positive), 0); + EXPECT_EQ(countOpaquePixels(positivePixmap, accent), 0); + + const QPixmap negativePixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg"), AppColor::Negative); + ASSERT_FALSE(negativePixmap.isNull()); + EXPECT_GT(countOpaquePixels(negativePixmap, negative), 0); + EXPECT_EQ(countOpaquePixels(negativePixmap, accent), 0); +} + +/** @brief A disabled control must not keep signalling its semantic color. */ +TEST_F(ThemeIconRenderTest, DisabledAppColorUsesDisabledForeground) +{ + QPalette palette = qApp->palette(); + const QColor disabledText(QStringLiteral("#fedcba")); + palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); + qApp->setPalette(palette); + + const IconTint tint(AppColor::Negative); + EXPECT_EQ(tint.resolve(QIcon::Disabled), disabledText); + EXPECT_NE(tint.resolve(QIcon::Normal), disabledText); +} + +/** @brief Palette tints keep the WindowText -> HighlightedText selected swap. */ +TEST_F(ThemeIconRenderTest, SelectedPaletteTintUsesHighlightedText) +{ + QPalette palette = qApp->palette(); + const QColor activeText(QStringLiteral("#111111")); + const QColor selectedText(QStringLiteral("#222222")); + palette.setColor(QPalette::Active, QPalette::WindowText, activeText); + palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText); + qApp->setPalette(palette); + + const IconTint tint(QPalette::WindowText); + EXPECT_EQ(tint.resolve(QIcon::Normal), activeText); + EXPECT_EQ(tint.resolve(QIcon::Selected), selectedText); +} + +/** @brief A selected row falls back to HighlightedText when the tint vanishes. */ +TEST_F(ThemeIconRenderTest, SelectedAppColorFallsBackWhenItMatchesTheHighlight) +{ + const QColor semantic = themeManager->appColor(AppColor::Positive); + const QColor selectedText(QStringLiteral("#fedcba")); + QPalette palette = qApp->palette(); + palette.setColor(QPalette::Active, QPalette::Highlight, semantic); + palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText); + qApp->setPalette(palette); + + EXPECT_EQ(IconTint(AppColor::Positive).resolve(QIcon::Selected), selectedText); +} + +/** @brief A selected row keeps a semantic tint that still contrasts enough. */ +TEST_F(ThemeIconRenderTest, SelectedAppColorKeepsAContrastingTint) +{ + QPalette palette = qApp->palette(); + palette.setColor(QPalette::Active, QPalette::Highlight, QColor(Qt::black)); + palette.setColor(QPalette::Active, QPalette::HighlightedText, QColor(Qt::white)); + qApp->setPalette(palette); + + 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) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + ASSERT_TRUE(tone.isValid()); + + const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + const QPixmap pixmap = icon.pixmap(QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(64, 64)); + + const QImage image = pixmap.toImage(); + // The disc fills the circle; the square corners stay transparent. + EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); + EXPECT_GT(image.pixelColor(32, 32).alpha(), 200); + + // The ring is the 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. + const int fillX = 10; + const QColor top = image.pixelColor(fillX, 16); + const QColor bottom = image.pixelColor(fillX, 48); + ASSERT_GT(top.alpha(), 200); + ASSERT_GT(bottom.alpha(), 200); + EXPECT_GT(top.lightness(), bottom.lightness()); + + // The glyph uses the contrast color chosen for this tone. + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0); +} + +/** + * @brief The 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) +{ + const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + const QPixmap hidpi = icon.pixmap(QSize(32, 32), 2.0); + + ASSERT_FALSE(hidpi.isNull()); + EXPECT_EQ(hidpi.size(), QSize(64, 64)); + EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0); + EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(32, 32)); +} + +/** + * @brief The player list is the one place the badge has to work, at 15px. + * + * A 20x15 icon box yields a 15px canvas. The art this replaces kept its check + * legible there, so the badge must still paint solid glyph ink rather than an + * antialiased smudge. + */ +TEST_F(ThemeIconRenderTest, BadgeStaysLegibleAtPlayerListSize) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + const QPixmap pixmap = icon.pixmap(QSize(20, 15)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.deviceIndependentSize(), QSizeF(15, 15)); + + // The disc survives: the corners stay transparent and the center is solid. + const QImage image = pixmap.toImage(); + EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); + EXPECT_GT(image.pixelColor(7, 7).alpha(), 200); + // A 1px ring on a 15px canvas antialiases into the disc and has no exact + // color, so the glyph is what has to stay legible. + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 8); +} + +/** @brief The pixmap API's badge path agrees with the engine's. */ +TEST_F(ThemeIconRenderTest, SentinelSvgBadgeMatchesTheEngine) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/ready_start.svg"), AppColor::Positive, + QSize(48, 48), IconStyle::Badge); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(48, 48)); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0); +} + +/** + * @brief Hides Qt's plugin directories while it lives, then restores them. + * + * Only the image-format plugins matter here: the platform plugin the fixture + * needs is already loaded by the time a test runs. + */ +class ScopedPluginPathHiding +{ +public: + ScopedPluginPathHiding() : saved(QCoreApplication::libraryPaths()) + { + QCoreApplication::setLibraryPaths({}); + } + ~ScopedPluginPathHiding() + { + QCoreApplication::setLibraryPaths(saved); + } + ScopedPluginPathHiding(const ScopedPluginPathHiding &) = delete; + ScopedPluginPathHiding &operator=(const ScopedPluginPathHiding &) = delete; + +private: + QStringList saved; +}; + +/** + * @brief Authored SVG art must not depend on Qt's SVG image-format plugin. + * + * Loading an `.svg` through QPixmap needs the separate `qsvg` image plugin. + * Distributions that build Qt with the plugin split out (Debian, and therefore + * CI) ship it in its own package, and a `--no-install-recommends` install does + * not have it, so authored theme art rendered blank there while working on a + * developer machine whose Qt bundles the format. Hiding the plugin directories + * reproduces that install. + */ +TEST_F(ThemeIconRenderTest, AuthoredArtDoesNotNeedTheSvgImagePlugin) +{ + 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()); + + // The offscreen platform plugin is already loaded by the fixture, so an + // empty library path only removes the image-format plugins. + const ScopedPluginPathHiding hidePlugins; + + const QIcon icon = loadThemedIcon(QStringLiteral("plain")); + const QPixmap pixmap = icon.pixmap(QSize(32, 32)); + + ASSERT_FALSE(pixmap.isNull()) << "authored SVG art went through the QPixmap loader"; + EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0); +} + +/** @brief Badge never paints authored art: the asset keeps its own colors. */ +TEST_F(ThemeIconRenderTest, BadgeOnAuthoredArtUsesTheAssetPath) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("#336699")); + file.close(); + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + const QColor tone = themeManager->appColor(AppColor::Positive); + const QIcon icon = loadThemedIcon(QStringLiteral("plain"), AppColor::Positive, IconStyle::Badge); + const QPixmap pixmap = icon.pixmap(QSize(32, 32)); + ASSERT_FALSE(pixmap.isNull()); + + // The authored color survives; no disc tone and no ring are painted. + EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); + EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); +} + +/** @brief Disabled keeps the shape language but drops back to the line glyph. */ +TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph) +{ + QPalette palette = qApp->palette(); + const QColor disabledText(QStringLiteral("#fedcba")); + palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); + qApp->setPalette(palette); + + const QColor tone = themeManager->appColor(AppColor::Positive); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + const QPixmap pixmap = icon.pixmap(QSize(64, 64), QIcon::Disabled); + ASSERT_FALSE(pixmap.isNull()); + + // A filled disc at full mass would not read as unavailable, so Disabled + // drops the badge: no ring, no disc, just the disabled line glyph. + EXPECT_EQ(countOpaquePixels(pixmap, 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) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + const QPixmap pixmap = + loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive).pixmap(QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + + EXPECT_EQ(countOpaquePixels(pixmap, 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}, + {"delete", 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 + 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])); + } + } + + 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). 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) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + struct Glyph + { + const char *name; + AppColor::Role role; + }; + constexpr Glyph badged[] = { + {"ready_start", AppColor::Positive}, + {"not_ready_start", AppColor::Negative}, + }; + constexpr int SIZES[] = {15, 16, 24, 48}; + + const auto readSvg = [](const char *name) { + QFile file( + QDir(resourcesDir()) + .absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg"))); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); + }; + const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 90; + constexpr int CELL_WIDTH = 120; + constexpr int ROW_HEIGHT = 58; + constexpr int GLYPH_BLOCK_HEIGHT = 22 + 2 * ROW_HEIGHT; + constexpr qreal DPR = 2.0; + + const int logicalWidth = MARGIN + LABEL_WIDTH + static_cast(std::size(SIZES)) * CELL_WIDTH + MARGIN; + const int logicalHeight = MARGIN + 24 + 24 + static_cast(std::size(badged)) * GLYPH_BLOCK_HEIGHT + 30 + MARGIN; + const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)); + + for (const bool dark : {false, true}) { + const QColor window(dark ? QStringLiteral("#1e1e1e") : QStringLiteral("#ffffff")); + const QColor text(dark ? QStringLiteral("#e8e8e8") : QStringLiteral("#1e1e1e")); + + QImage image(imageSize, QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9)); + + qreal y = MARGIN + 4; + painter.drawText(QPointF(MARGIN, y), dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")); + + y += 24; + painter.drawText(QPointF(MARGIN, y), QStringLiteral("Ready / not ready (line above, badge below)")); + for (const Glyph &glyph : badged) { + y += GLYPH_BLOCK_HEIGHT; + const QByteArray svg = readSvg(glyph.name); + ASSERT_FALSE(svg.isEmpty()) << glyph.name; + // 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; + const qreal rowTop = y - GLYPH_BLOCK_HEIGHT + 22 + row * ROW_HEIGHT; + painter.drawText(QPointF(MARGIN, rowTop + 16), + (badge ? QStringLiteral("badge ") : QStringLiteral("line ")) + + QString::fromLatin1(glyph.name)); + for (std::size_t i = 0; i < std::size(SIZES); ++i) { + const int side = SIZES[i]; + const QPixmap pixmap = badge ? ThemeIconBadge::renderBadge(svg, tone, QSize(side, side)) + : renderLine(svg, tone, QSize(side, side)); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; + // Align all sizes on a common baseline so the weight + // difference between 15px and 48px is visible. + painter.drawPixmap(QPointF(x, rowTop + 36 - side), pixmap); + if (row == 1) { + painter.drawText(QPointF(x, rowTop + 54), QStringLiteral("%1px").arg(side)); + } + } + } + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("badge-preview-dark.png") + : QStringLiteral("badge-preview-light.png")); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index a953c9f0b..7e769b1bd 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -153,6 +153,41 @@ TEST_F(ThemeMigrationTest, IsIdempotent) EXPECT_EQ(readThemeFile(themesPath, CURRENT, "style.css").toStdString(), std::string("css")); } +TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme) +{ + EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1b7f3b"))); + EXPECT_EQ(defaultAppColor(AppColor::Positive, true), QColor(QStringLiteral("#6dd58c"))); + EXPECT_EQ(defaultAppColor(AppColor::Negative, false), QColor(QStringLiteral("#b3261e"))); + EXPECT_EQ(defaultAppColor(AppColor::Negative, true), QColor(QStringLiteral("#ff8a80"))); + // Accent roles are derived from the palette, so they have no static default. + EXPECT_FALSE(defaultAppColor(AppColor::AccentStrong, false).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::AccentSoft, true).isValid()); +} + +TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + PaletteConfig config; + const QColor positive(QStringLiteral("#1b7f3b")); + const QColor negative(QStringLiteral("#b3261e")); + config.appColors[AppColor::Positive] = positive; + config.appColors[AppColor::Negative] = negative; + + const QString path = QDir(dir.path()).absoluteFilePath(QStringLiteral("palette-light.toml")); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text)); + file.write(config.toToml().toUtf8()); + file.close(); + + const PaletteConfig parsed = PaletteConfig::fromFile(path); + ASSERT_TRUE(parsed.appColors.contains(AppColor::Positive)); + ASSERT_TRUE(parsed.appColors.contains(AppColor::Negative)); + EXPECT_EQ(parsed.appColors.value(AppColor::Positive), positive); + EXPECT_EQ(parsed.appColors.value(AppColor::Negative), negative); +} + // Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and // the fallback then links the bare gtest library without gtest_main. int main(int argc, char **argv)