mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-08 11:03:25 -08:00
[Icons] Tint semantic glyphs and add badge and sticker treatments (#7775)
* [Icons] Keep semantic red and green on themed glyphs The one-glyph cutover recolored every icon from a single palette role, so a disabled deck, a not-ready player and a remove button all lost the red or green that carried their meaning. Add Positive and Negative application colors beside the accents, ship scheme-appropriate defaults for every theme, and resolve them through ThemeManager. Icons now take an IconTint: a palette role for structural glyphs, or a semantic role for states and add/remove actions. A disabled semantic icon falls back to the disabled foreground; on a selected row a semantic tint keeps its color only while it stays legible on the highlight, and a palette tint keeps the existing WindowText -> HighlightedText swap. * [Icons] Render a semantic-tint sheet for every shipped theme The PR showed two sheets for one theme, which cannot show that the shipped reds and greens stay legible on every palette. Each shipped theme and scheme now gets its own sheet: both families, three glyphs, at 16 and 24 px, on the window and button surfaces the contrast is measured against, with the disabled tone beside each family. The generator lives in its own test file so the preview work does not have to move the branches above it. * [Icons] Add a badge icon treatment Restore the filled-disc look the ready/not-ready icons had before the one-glyph cutover, without bringing back palette-locked art. IconStyle::Badge composes the existing sentinel SVG at paint time: a gradient disc, a dark ring, and the glyph re-weighted and inked for contrast against the tone. Line stays the default and is unchanged. Only the two state icons opt in. The add/remove icons keep the line treatment because their old art was a filled glyph with an outline and no disc, so badging them would invent a look the UI never had. Disabled badges fall back to the line glyph so an unavailable control still reads as unavailable. * [Icons] Add a sticker icon treatment The pre-#7595 add, remove and delete icons were filled shapes with a black outline and no disc. A tint cannot bring that back, and the badge disc is a different icon language, so add a third value to the existing style axis. IconStyle::Sticker draws the sentinel glyph twice: a near-black rim at the band width plus one rim on each side, then the tone pass clipped to the glyph and filled with a vertical gradient. Both treatments keep the disabled fallback to the line glyph. The fill rule moves into theme_icon_fill: the semantic tones are tuned for text, so a filled shape gives its tone a saturation floor, and a dark-scheme tone is pulled down rather than up. IconTint::resolve() applies the same floor to line glyphs, and the badge disc uses the same fill, so a line, a badge and a sticker next to each other carry the same green or red instead of a pale line beside a saturated glyph. The badge's glyph ink follows the scheme: white on light-scheme tones, as in the pre-#7595 art, and the saturated tone darkened to an outline-dark ink on dark-scheme tones, where the shared saturated disc is light enough that white would sit near 2.7:1. The new test pins both cases so the glyph cannot silently flip. resolveSvgStrokeWidth() moves the stroke-width rewrite into libcockatrice_utility so the badge and the sticker share it; the badge's local copy is gone. The sticker is opted into at 17 call sites: increment, decrement and remove_row in the deck editor, settings pages, token dialog, server dialogs and replay/deck storage tabs. The visual database's clear-filter button keeps its trash can (icons/delete) in the line treatment: that glyph is new in #7595, but it is the clearer metaphor, and a sticker rim closes its lid gap at toolbar sizes. The preview generators also gain a semantic-tint sheet for #7775 and a line/badge/sticker family strip, so the three PR descriptions can show the same rendered colors. * [Icons] Render authored theme SVG art without the SVG image plugin `QPixmap("theme:file.svg")` needs Qt's SVG *image-format* plugin. Debian builds that as the separate `qt6-svg-plugins` package, which is only a Recommends of `qt6-svg-dev`, so a `--no-install-recommends` install (CI, minimal user installs) does not have it. Authored theme art then loaded as a null pixmap: the two authored-art render tests failed on Debian 13 and on the Ubuntu sanitize leg while passing on a developer machine whose Qt bundles the format. Render authored SVG assets through QSvgRenderer instead, which is already linked for the sentinel path and needs no plugin. Raster assets keep the QPixmap loader. The test suite now hides the plugin directories, so the dependency cannot come back unnoticed on platforms that ship the plugin. * [Icons] Render a badge/sticker sheet for every shipped theme The PR showed one theme's badge and sticker sheets, which cannot show that the filled treatments read on every palette. Each shipped theme and scheme now gets its own sheet: both semantic families, three glyphs, as line glyphs, stickers and badges, at 16 and 24 px, on the window and button surfaces the contrast is measured against, with the disabled fallback under each family. The generator lives in its own test file so the preview work does not have to move the branches above it. * [Icons] Show the trash can with the sticker treatment again The sticker sheet kept the delete glyph out of the sticker rows and explained why: at toolbar sizes the rim closed the lid gap. The icon is used with the sticker treatment again for destructive actions, so the sheet now shows it and the exception note is gone. * [Icons] Apply the CI clang-format output to the treatment preview clang-format 18 wraps the family label and the glyph x offset differently from the local clang-format 23. The preview test takes the CI output so the bottom of the stack passes the style job on its own. * [Icons] Rewrite the icon role docs in plain language Address review: drop the AI-cadence prose from the IconStyle/IconTint and treatment headers, use ///< for the enum member docs, and move theme_icon.cpp's anonymous namespace above the IconTint definitions. No behavior change. * Address review findings --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
parent
1ed7e45357
commit
e81ee02c24
49 changed files with 2351 additions and 72 deletions
|
|
@ -63,4 +63,57 @@ TEST(SvgRecolor, NoSentinelIsANoOp)
|
|||
EXPECT_EQ(resolveSvgColorSentinel(svg, "#ffffff"), svg);
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, RewritesEveryAttribute)
|
||||
{
|
||||
const QByteArray svg = R"(<svg stroke-width="2"><path stroke-width="1.5"/><path stroke-width='3'/></svg>)";
|
||||
|
||||
const QByteArray result = resolveSvgStrokeWidth(svg, 4.25);
|
||||
|
||||
EXPECT_EQ(result.count(R"(stroke-width="4.25")"), 2);
|
||||
EXPECT_EQ(result.count(R"(stroke-width='4.25')"), 1);
|
||||
EXPECT_FALSE(result.contains("1.5"));
|
||||
EXPECT_FALSE(result.contains("3'/"));
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, LeavesCssDeclarationsAlone)
|
||||
{
|
||||
const QByteArray svg = R"(<svg><path style="stroke-width:2"/><path stroke-width="2"/></svg>)";
|
||||
|
||||
const QByteArray result = resolveSvgStrokeWidth(svg, 3.0);
|
||||
|
||||
EXPECT_TRUE(result.contains(R"(style="stroke-width:2")"));
|
||||
EXPECT_EQ(result.count(R"(stroke-width="3.00")"), 1);
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, NoAttributeIsANoOp)
|
||||
{
|
||||
const QByteArray svg = R"(<svg stroke-linecap="round"><path d="M0 0h24"/></svg>)";
|
||||
|
||||
EXPECT_EQ(resolveSvgStrokeWidth(svg, 3.0), svg);
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, DoesNotModifyTheInputAndPreservesBytes)
|
||||
{
|
||||
QByteArray svg = "<svg>";
|
||||
svg.append("\xff\xfe");
|
||||
svg.append(R"(<path stroke-width="2"/></svg>)");
|
||||
const QByteArray original = svg;
|
||||
|
||||
const QByteArray result = resolveSvgStrokeWidth(svg, 3.5);
|
||||
|
||||
EXPECT_EQ(svg, original);
|
||||
EXPECT_TRUE(result.contains("\xff\xfe"));
|
||||
EXPECT_TRUE(result.contains(R"(stroke-width="3.50")"));
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, TruncatedAttributeTerminates)
|
||||
{
|
||||
// A quote that is never closed must not over-read or hang.
|
||||
const QByteArray svg = R"(<svg><path stroke-width="2/></svg>)";
|
||||
|
||||
const QByteArray result = resolveSvgStrokeWidth(svg, 3.0);
|
||||
|
||||
EXPECT_TRUE(result.startsWith("<svg>"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
227
tests/theme_migration/icon_tint_preview_test.cpp
Normal file
227
tests/theme_migration/icon_tint_preview_test.cpp
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
#include "interface/theme_config.h"
|
||||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QByteArray>
|
||||
#include <QColor>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFont>
|
||||
#include <QGuiApplication>
|
||||
#include <QImage>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPointF>
|
||||
#include <QSize>
|
||||
#include <QStringList>
|
||||
#include <QStringLiteral>
|
||||
#include <QSvgRenderer>
|
||||
#include <gtest/gtest.h>
|
||||
#include <iterator>
|
||||
|
||||
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 <typename Render> 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<Family> 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<int>(std::size(families.first().glyphs));
|
||||
const int columnCount = glyphCount * static_cast<int>(std::size(SIZES));
|
||||
const int panelWidth = LABEL_WIDTH + columnCount * SLOT + PANEL_PADDING;
|
||||
const int panelHeight = PANEL_TITLE + static_cast<int>(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<Surface> 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<qreal>(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<qreal>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
245
tests/theme_migration/icon_treatment_preview_test.cpp
Normal file
245
tests/theme_migration/icon_treatment_preview_test.cpp
Normal file
|
|
@ -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 <QApplication>
|
||||
#include <QByteArray>
|
||||
#include <QColor>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFont>
|
||||
#include <QGuiApplication>
|
||||
#include <QImage>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPointF>
|
||||
#include <QSize>
|
||||
#include <QStringList>
|
||||
#include <QStringLiteral>
|
||||
#include <QSvgRenderer>
|
||||
#include <gtest/gtest.h>
|
||||
#include <iterator>
|
||||
|
||||
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 <typename Render> 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<Family> 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<int>(std::size(TREATMENTS));
|
||||
const int panelWidth = LABEL_WIDTH + treatmentCount * TREATMENT_WIDTH + PANEL_PADDING;
|
||||
const int panelHeight =
|
||||
PANEL_TITLE + COLUMN_HEADER + static_cast<int>(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<Surface> 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<qreal>(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<qreal>(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<qreal>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <QApplication>
|
||||
#include <QByteArray>
|
||||
#include <QColor>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFont>
|
||||
#include <QGuiApplication>
|
||||
#include <QImage>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPointF>
|
||||
#include <QSize>
|
||||
#include <QStringList>
|
||||
#include <QStringLiteral>
|
||||
#include <QSvgRenderer>
|
||||
#include <QTemporaryDir>
|
||||
#include <gtest/gtest.h>
|
||||
#include <iterator>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
|
@ -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"(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill=")") +
|
||||
|
|
@ -187,3 +223,877 @@ TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved)
|
|||
ASSERT_FALSE(pixmap.isNull());
|
||||
EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#123456"))), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A semantic tint must paint its theme color, not the selection accent.
|
||||
*
|
||||
* A theme written before Positive/Negative existed has no stored value, so this
|
||||
* also covers the fallback that used to return the highlight color.
|
||||
*/
|
||||
TEST_F(ThemeIconRenderTest, AppColorTintUsesSemanticColorNotTheAccent)
|
||||
{
|
||||
QPalette palette = qApp->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<int>(std::size(SIZES)) * CELL_WIDTH + MARGIN;
|
||||
const int logicalHeight = MARGIN + 24 + 34 + static_cast<int>(std::size(tinted)) * ROW_HEIGHT + 40 + MARGIN;
|
||||
const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR));
|
||||
|
||||
for (const bool dark : {false, true}) {
|
||||
const QColor window(dark ? QStringLiteral("#1e1e1e") : QStringLiteral("#ffffff"));
|
||||
const QColor text(dark ? QStringLiteral("#e8e8e8") : QStringLiteral("#1e1e1e"));
|
||||
|
||||
QImage image(imageSize, QImage::Format_ARGB32_Premultiplied);
|
||||
image.fill(window);
|
||||
QPainter painter(&image);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
painter.scale(DPR, DPR);
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 9));
|
||||
|
||||
qreal y = MARGIN + 4;
|
||||
painter.drawText(QPointF(MARGIN, y), dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme"));
|
||||
|
||||
y += 24;
|
||||
painter.drawText(QPointF(MARGIN, y), QStringLiteral("Semantic tints on line glyphs (#7775)"));
|
||||
y += 10;
|
||||
|
||||
for (const Glyph &glyph : tinted) {
|
||||
y += ROW_HEIGHT;
|
||||
const qreal rowTop = y - ROW_HEIGHT;
|
||||
QFile file(QDir(resourcesDir())
|
||||
.absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(glyph.name) +
|
||||
QStringLiteral(".svg")));
|
||||
ASSERT_TRUE(file.open(QIODevice::ReadOnly)) << glyph.name;
|
||||
const QByteArray svg = file.readAll();
|
||||
const QColor tone = defaultAppColor(glyph.role, dark);
|
||||
painter.drawText(QPointF(MARGIN, rowTop + 16), QString::fromLatin1(glyph.name));
|
||||
for (std::size_t i = 0; i < std::size(SIZES); ++i) {
|
||||
const int side = SIZES[i];
|
||||
const QPixmap pixmap = renderLine(svg, tone, QSize(side, side));
|
||||
const qreal x = MARGIN + LABEL_WIDTH + static_cast<qreal>(i) * CELL_WIDTH;
|
||||
// Bottom-align the sizes and put the caption below the 48px row.
|
||||
painter.drawPixmap(QPointF(x, rowTop + 54 - side), pixmap);
|
||||
painter.drawText(QPointF(x, rowTop + 66), QStringLiteral("%1px").arg(side));
|
||||
}
|
||||
}
|
||||
|
||||
y += 20;
|
||||
painter.drawText(QPointF(MARGIN, y),
|
||||
QStringLiteral("Positive: ready and add. Negative: not ready, remove and delete."));
|
||||
|
||||
painter.end();
|
||||
const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("tint-preview-dark.png")
|
||||
: QStringLiteral("tint-preview-light.png"));
|
||||
ASSERT_TRUE(image.save(path)) << path.toStdString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Writes the sticker preview sheet when asked; skipped otherwise.
|
||||
*
|
||||
* The sheet shows each add/remove glyph as line and as sticker at the
|
||||
* sizes the UI uses (15px in the player list and menus, 16px in toolbars, 24 and
|
||||
* 48 for inspection), with the ready/not-ready badge for context. It lives in the
|
||||
* test binary so the colors come from the same place the engine uses.
|
||||
*/
|
||||
TEST(IconStickerPreview, WritesPngsWhenRequested)
|
||||
{
|
||||
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||||
ensureGuiApplication();
|
||||
|
||||
const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR");
|
||||
if (outDir.isEmpty()) {
|
||||
GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs";
|
||||
}
|
||||
ASSERT_TRUE(QDir().mkpath(outDir));
|
||||
|
||||
struct Glyph
|
||||
{
|
||||
const char *name;
|
||||
AppColor::Role role;
|
||||
};
|
||||
constexpr Glyph stickered[] = {
|
||||
{"increment", AppColor::Positive},
|
||||
{"decrement", AppColor::Negative},
|
||||
{"remove_row", AppColor::Negative},
|
||||
{"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<int>(std::size(SIZES)) * CELL_WIDTH + MARGIN;
|
||||
const int logicalHeight = MARGIN + 24 + 24 + static_cast<int>(std::size(stickered)) * GLYPH_BLOCK_HEIGHT + 24 + 24 +
|
||||
CONTEXT_SIZE + 46 + CONTEXT_SIZE + 46 + MARGIN;
|
||||
const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR));
|
||||
|
||||
for (const bool dark : {false, true}) {
|
||||
const QColor window(dark ? QStringLiteral("#1e1e1e") : QStringLiteral("#ffffff"));
|
||||
const QColor text(dark ? QStringLiteral("#e8e8e8") : QStringLiteral("#1e1e1e"));
|
||||
|
||||
QImage image(imageSize, QImage::Format_ARGB32_Premultiplied);
|
||||
image.fill(window);
|
||||
QPainter painter(&image);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
painter.scale(DPR, DPR);
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 9));
|
||||
|
||||
qreal y = MARGIN + 4;
|
||||
painter.drawText(QPointF(MARGIN, y), dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme"));
|
||||
|
||||
y += 24;
|
||||
painter.drawText(QPointF(MARGIN, y), QStringLiteral("Add / remove (line above, sticker below)"));
|
||||
for (const Glyph &glyph : stickered) {
|
||||
y += GLYPH_BLOCK_HEIGHT;
|
||||
const QByteArray svg = readSvg(glyph.name);
|
||||
ASSERT_FALSE(svg.isEmpty()) << glyph.name;
|
||||
// The preview must show the line glyph the engine paints, and the
|
||||
// engine saturates AppColor tones before drawing them.
|
||||
const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark));
|
||||
|
||||
for (int row = 0; row < 2; ++row) {
|
||||
const bool sticker = row == 1;
|
||||
const qreal rowTop = y - GLYPH_BLOCK_HEIGHT + 22 + row * ROW_HEIGHT;
|
||||
painter.drawText(QPointF(MARGIN, rowTop + 16),
|
||||
(sticker ? QStringLiteral("sticker ") : QStringLiteral("line ")) +
|
||||
QString::fromLatin1(glyph.name));
|
||||
for (std::size_t i = 0; i < std::size(SIZES); ++i) {
|
||||
const int side = SIZES[i];
|
||||
const QPixmap pixmap = sticker ? ThemeIconSticker::renderSticker(svg, tone, QSize(side, side))
|
||||
: renderLine(svg, tone, QSize(side, side));
|
||||
const qreal x = MARGIN + LABEL_WIDTH + static_cast<qreal>(i) * CELL_WIDTH;
|
||||
painter.drawPixmap(QPointF(x, rowTop + 36 - side), pixmap);
|
||||
if (row == 1) {
|
||||
painter.drawText(QPointF(x, rowTop + 54), QStringLiteral("%1px").arg(side));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
y += 24;
|
||||
painter.drawText(QPointF(MARGIN, y),
|
||||
QStringLiteral("Ready / not ready keep the badge (%1px)").arg(CONTEXT_SIZE));
|
||||
constexpr Glyph badgeContext[] = {
|
||||
{"ready_start", AppColor::Positive},
|
||||
{"not_ready_start", AppColor::Negative},
|
||||
};
|
||||
for (std::size_t i = 0; i < std::size(badgeContext); ++i) {
|
||||
const QByteArray svg = readSvg(badgeContext[i].name);
|
||||
ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name;
|
||||
const QColor tone = defaultAppColor(badgeContext[i].role, dark);
|
||||
const qreal x = MARGIN + LABEL_WIDTH + static_cast<qreal>(i) * CELL_WIDTH;
|
||||
painter.drawPixmap(QPointF(x, y + 8),
|
||||
ThemeIconBadge::renderBadge(svg, tone, QSize(CONTEXT_SIZE, CONTEXT_SIZE)));
|
||||
painter.drawText(QPointF(x, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(badgeContext[i].name));
|
||||
}
|
||||
|
||||
y += CONTEXT_SIZE + 46;
|
||||
painter.drawText(QPointF(MARGIN, y),
|
||||
QStringLiteral("Line / badge / sticker share one tone (%1px)").arg(CONTEXT_SIZE));
|
||||
constexpr const char *FAMILY_LABELS[] = {"line", "badge", "sticker"};
|
||||
for (std::size_t i = 0; i < std::size(badgeContext); ++i) {
|
||||
const QByteArray svg = readSvg(badgeContext[i].name);
|
||||
ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name;
|
||||
const QColor tone = ThemeIconFill::saturated(defaultAppColor(badgeContext[i].role, dark));
|
||||
const qreal x = MARGIN + LABEL_WIDTH + static_cast<qreal>(i) * CELL_WIDTH * 1.5;
|
||||
for (int j = 0; j < 3; ++j) {
|
||||
const QSize side(CONTEXT_SIZE, CONTEXT_SIZE);
|
||||
QPixmap pixmap;
|
||||
if (j == 0) {
|
||||
pixmap = renderLine(svg, tone, side);
|
||||
} else if (j == 1) {
|
||||
pixmap = ThemeIconBadge::renderBadge(svg, tone, side);
|
||||
} else {
|
||||
pixmap = ThemeIconSticker::renderSticker(svg, tone, side);
|
||||
}
|
||||
const qreal px = x + j * (CONTEXT_SIZE + 16);
|
||||
painter.drawPixmap(QPointF(px, y + 8), pixmap);
|
||||
painter.drawText(QPointF(px, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(FAMILY_LABELS[j]));
|
||||
}
|
||||
}
|
||||
|
||||
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<int>(std::size(SIZES)) * CELL_WIDTH + MARGIN;
|
||||
const int logicalHeight = MARGIN + 24 + 24 + static_cast<int>(std::size(badged)) * GLYPH_BLOCK_HEIGHT + 30 + MARGIN;
|
||||
const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR));
|
||||
|
||||
for (const bool dark : {false, true}) {
|
||||
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<qreal>(i) * CELL_WIDTH;
|
||||
// Align all sizes on a common baseline so the weight
|
||||
// difference between 15px and 48px is visible.
|
||||
painter.drawPixmap(QPointF(x, rowTop + 36 - side), pixmap);
|
||||
if (row == 1) {
|
||||
painter.drawText(QPointF(x, rowTop + 54), QStringLiteral("%1px").arg(side));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue