mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-09 11:33:25 -08:00
* [Settings] Add hover zoom/panel triggers, delays, and double-confirm * [Game] Smooth board hover-zoom with fade helpers * [Game] Add hover card panel with tabbed actions * [Game] Wire hover zoom/panel triggers through the scene * [Game] Repaint cached zones when the hover zoom moves `TableZone` cuts the hovered card out of its own paint so zone washes, indicators, dimming and the land divider never draw over the zoom. It is a `DeviceCoordinateCache` item, so the card changing geometry never dirtied it and the stale hole stayed transparent, uncovering the black viewport as soon as the zoom crossed a zone edge. - Emit `hoverFootprintChanged(old, new)` from `setCardScale()` and once from `setHovered(false)`. - Repaint every overlapping `TableZone` on that signal. - Clip with the card's painted outline instead of its bounding rectangle, and treat a zoom covering the whole zone as "paint nothing". * [Interface] Keep the hover panel P/T controls out of the way The P/T set/inc/dec rows were always visible and sat under the cursor, so clicking a card to select it could mutate its P/T instead. - Collapse the controls to a pill on the printed P/T box, anchored on the drawn artwork so letterboxed and sideways cards stay aligned. - Open the rows only from the pill and grow them upward, and re-measure the pill on every value so three-digit P/T does not clip. - Close the panel when another card is hovered, and repaint the area it covered so no patch is left behind. * [Interface] Address hover panel review findings * [Game] Fix include order flagged by the format gate The card hover widget header was included before theme_manager.h, which clang-format rejects; the branch's format CI job failed on it. * [Interface] Load the hover panel icons through the themed icon engine The panel still named pre-#7595 assets (arrow_*_green, arrow_right_blue, cogwheel) and built QIcon snapshots from themePixmap(), so the deleted names rendered blank and every other glyph froze one palette. Load every glyph through loadThemedIcon() with the emphasis axis the adoption plan calls for: the category tabs take the primary level as panel wayfinding, the action grid the quiet level because its glyphs label the button text. Semantic tints keep their family and follow the same quiet level; Clone keeps its sticker treatment. * [Icons] Add an Informational semantic color family Face-down play and the exile zone are states that are neither a gain nor a loss, and the pre-#7595 art painted them blue (arrow_right_blue). #7595 dropped that hue, and the icon engine has had only Positive and Negative since, so both states fall back to neutral ink. Add the family with the same three-level ladder the other semantic roles use: authored roles per theme, scheme defaults otherwise, an emphasis mapping, the palette-editor rows and the generator seed. The defaults clear the 4.5:1 label floor on both schemes and the ladder's saturation ordering; the existing emphasis, hue and preview tests now cover the new family. Preview sheets that name roles resolve an unauthored one through the same default the client paints. * [Interface] Split the hover panel icons by emphasis and semantics Category tabs only navigate between pages, so they take the quiet level; the action grid is the surface the player works in and takes the primary level. Both stay line glyphs: a filled treatment reads as a second state on every cell. Hue marks an outcome where the glyph cannot. Play, clone and to hand are Positive; reduce life and graveyard Negative; play face down and exile Informational. Direction-only moves (top and bottom of library) stay neutral because the arrow already carries the meaning. * [Icons] Add Hidden and Marked semantic color families Two game concepts had no hue of their own. Hidden information (a face-down play, peeking at a card face) now has violet, and the marks a player leaves on a card (annotation, draw arrow) now share amber instead of splitting blue and red for the same job. Both families follow the existing three-level ladder: authored roles per theme, scheme defaults otherwise, an emphasis mapping, palette-editor rows and generator seeding. The defaults clear the 4.5:1 floor on the representative platform surfaces and keep the saturation ordering the emphasis tests assert; the hue tests pin violet's blue-red-green dominance and amber's red-green-blue one, and every preview sheet now renders the two families. The hover panel grid preview is added alongside: it renders the real pages (section headers, two-column cells, glyph over label) so the whole map can be judged at once instead of glyph by glyph. * [Icons] Add a Movement semantic color family A card changing position inside the library is neither a gain, a loss nor information, so it had no hue. Movement names it with teal and covers the three library placements (top, bottom, X from top). It follows the existing ladder: authored roles per theme, scheme defaults otherwise, an emphasis mapping, palette-editor rows and generator seeding. The defaults clear the 4.5:1 floor on the representative platform surfaces, keep strong > base > soft saturation, and keep green > blue > red so teal cannot be mistaken for the green gain family. * [Interface] Give every hover panel action a semantic color The quick actions are the panel's whole point, so a neutral glyph made the most-used buttons the hardest to scan. Every action now names a game fact with its hue: gain, loss, information, hidden information, a mark on the card or a move. The line glyph stays identical, so the colour is the only difference between neighbours. The grid is also ordered by family so a row reads as one idea: the two state toggles, the two gains, the two marks, the two hidden-information actions and the two losses each share a row. Conditional actions (Peek, Unattach) sit at the end of their pair, so a missing one leaves a hole at the section edge instead of splitting a row. In a zone, Play and Play Face Down stay adjacent on purpose: the same action, one of them hiding information. The move tab leads with the three library placements (Movement), then the destinations. actionGridIcon is removed; semanticActionIcon is now the only grid ink. The preview renders the real pages so the whole map can be judged at once. * [Icons] Use quiet emphasis for glyphs that label rather than act The search and info glyphs inside the search fields, and the combo-box chevron, label or afford an action rather than performing one. At the default secondary weight they compete with the actual controls next to them. Paint them at quiet emphasis so they stay legible without claiming attention. Sites: deck editor, visual database display, visual deck storage, set manager, settings search, in-game zone view, and the dropdown chevron. * [Icons] Restore sticker treatment to the add, remove and delete actions The pre-#7595 add and delete glyphs were filled shapes with an outline. After the line migration they read as ordinary toolbar glyphs, losing the weight their verbs had. Give the add and delete family the sticker treatment again. - Deck editor Add card: primary positive sticker. It is the one action the database panel exists for. - Visual database Clear filters: negative sticker, matching the delete family. - Filter dock Clear all: negative sticker, matching Clear one. * [Icons] Give sharing and pinning their semantic families Sharing is an informational act: the app already tints reveal and make-public with the informational family, so the deck storage share action, the replay share-code action and the visual deck storage share button follow it. The printing-selector pin badge marks a card as pinned. Paint it with the marked family, the same one used for a mark left on a card, instead of leaving it neutral. * [Game] Dismiss the hover panel before opening a modal dialog The hover panel is a Qt::ToolTip window, i.e. a popup: it is always on top and holds the input grab. Actions on the panel that open a modal dialog (Set power/toughness, Set annotation, Set counter value, token creation) therefore opened the dialog behind the panel, where it could not be seen or activated. Hide the panel synchronously and defer the action to the next event-loop turn, so the popup grab is released before the dialog's event loop starts. The panel asks the scene to dismiss it through a new dismissRequested signal, keeping hover timers and the covered-card fade on the scene's side. Reported from the P/T overlay SET button; the same path covers the counter tab and the token tab. * [Icons] Refine VDE and VDD emphasis and semantics The visual database filter section and the syntax help are information aids, so paint them with the informational family at the default emphasis: the filter section glyph and the search field's help glyph. The help glyph leaves quiet emphasis because it is a clickable action, not a field label. The visual deck editor's display-type toggle is the one control that changes the whole view, so it takes primary emphasis. The search glyphs stay quiet; only the help action changes. * [Icons] Raise the visual database filter controls to primary The six quick-filter buttons are the controls the filter section exists for, so paint them at primary emphasis instead of the default weight. The sort glyph carries the same informational family as the filter glyph, in both the visual database toolbar label and the visual deck editor's sort-criteria button. * [Icons] Keep the display options sort glyph neutral The sort button beside the display-mode toggle is not part of the informational family. Only the visual database toolbar's sort glyph carries informational; the display options button returns to the default neutral weight. --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2324 lines
108 KiB
C++
2324 lines
108 KiB
C++
#include "interface/theme_icon.h"
|
||
#include "interface/theme_icon_badge.h"
|
||
#include "interface/theme_icon_fill.h"
|
||
#include "interface/theme_icon_ink.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>
|
||
#include <tuple>
|
||
|
||
namespace
|
||
{
|
||
/**
|
||
* @brief Creates the GUI application QPixmap needs, using the offscreen plugin.
|
||
*
|
||
* The theme-migration binary is headless; creating this lazily keeps its other
|
||
* tests free of a display dependency.
|
||
*/
|
||
void ensureGuiApplication()
|
||
{
|
||
static int argc = 1;
|
||
static char name[] = "theme_icon_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(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
|
||
}
|
||
|
||
int countOpaquePixels(const QPixmap &pixmap, const QColor &expected)
|
||
{
|
||
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() == expected.red() && pixel.green() == expected.green() &&
|
||
pixel.blue() == expected.blue()) {
|
||
++count;
|
||
}
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
|
||
/**
|
||
* @brief The ink the engine resolves a neutral glyph to, for @p palette.
|
||
*
|
||
* Neutral glyphs are not painted with WindowText verbatim: theme_icon_ink holds
|
||
* the platform text color to a contrast floor. Tests that pin the rendered color
|
||
* ask the rule instead of restating it. No accent chroma is borrowed: this is
|
||
* the fallback an unauthored neutral family takes, so it works whatever scheme
|
||
* the platform palette actually is.
|
||
*/
|
||
QColor expectedNeutralInk(const QPalette &palette)
|
||
{
|
||
const QColor text = palette.color(QPalette::Active, QPalette::WindowText);
|
||
const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window),
|
||
palette.color(QPalette::Active, QPalette::Button), text);
|
||
return ThemeIconInk::glyphInk(text, surface, palette.color(QPalette::Active, QPalette::Highlight), 0.0);
|
||
}
|
||
|
||
/**
|
||
* @brief Renders a glyph at the sheet's device pixel ratio.
|
||
*
|
||
* The preview sheets paint through a painter scaled by @p dpr, so a pixmap
|
||
* rendered at logical size would be upscaled on the way into the file and read
|
||
* as blurry. Rendering at device size and tagging the pixmap keeps one source
|
||
* pixel per device pixel.
|
||
*/
|
||
template <typename Render> QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render)
|
||
{
|
||
QPixmap pixmap = render(logicalSize * dpr);
|
||
pixmap.setDevicePixelRatio(dpr);
|
||
return pixmap;
|
||
}
|
||
|
||
/** @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=")") +
|
||
colorToken + QByteArrayLiteral(R"(" d="M2 2h20v20H2z"/></svg>)");
|
||
}
|
||
|
||
/**
|
||
* @brief Restores the global theme search paths and palette after each test.
|
||
*
|
||
* The icon engine reads both process-wide values, so a failing assertion must
|
||
* not leak a temporary theme directory or palette into the next test.
|
||
*/
|
||
class ThemeIconRenderTest : public ::testing::Test
|
||
{
|
||
protected:
|
||
void SetUp() override
|
||
{
|
||
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||
ensureGuiApplication();
|
||
savedSearchPaths = QDir::searchPaths(QStringLiteral("theme"));
|
||
savedPalette = qApp->palette();
|
||
themeManager = new ThemeManager;
|
||
QDir::setSearchPaths(QStringLiteral("theme"), {resourcesDir()});
|
||
}
|
||
|
||
void TearDown() override
|
||
{
|
||
delete themeManager;
|
||
themeManager = nullptr;
|
||
QDir::setSearchPaths(QStringLiteral("theme"), savedSearchPaths);
|
||
qApp->setPalette(savedPalette);
|
||
}
|
||
|
||
QStringList savedSearchPaths; ///< Search paths captured before the test.
|
||
QPalette savedPalette; ///< Application palette captured before the test.
|
||
};
|
||
|
||
/** @brief A non-square request must letterbox, not squash, a 24×24 glyph. */
|
||
TEST_F(ThemeIconRenderTest, NonSquareRequestPreservesAspectRatio)
|
||
{
|
||
const QIcon icon = loadThemedIcon(QStringLiteral("icons/player"));
|
||
ASSERT_FALSE(icon.isNull());
|
||
|
||
EXPECT_EQ(icon.actualSize(QSize(20, 15)), QSize(15, 15));
|
||
|
||
const QPixmap pixmap = icon.pixmap(QSize(20, 15));
|
||
ASSERT_FALSE(pixmap.isNull());
|
||
EXPECT_EQ(pixmap.deviceIndependentSize(), QSizeF(15, 15));
|
||
}
|
||
|
||
/** @brief The engine must follow palette flips, icon modes, and DPR. */
|
||
TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr)
|
||
{
|
||
QPalette palette = qApp->palette();
|
||
palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456")));
|
||
qApp->setPalette(palette);
|
||
|
||
const QIcon icon = loadThemedIcon(QStringLiteral("icons/search"));
|
||
ASSERT_FALSE(icon.isNull());
|
||
|
||
const QColor firstInk = expectedNeutralInk(qApp->palette());
|
||
const QPixmap normal = icon.pixmap(QSize(24, 24), 1.0);
|
||
ASSERT_FALSE(normal.isNull());
|
||
EXPECT_EQ(normal.size(), QSize(24, 24));
|
||
EXPECT_DOUBLE_EQ(normal.devicePixelRatio(), 1.0);
|
||
EXPECT_GT(countOpaquePixels(normal, firstInk), 0);
|
||
|
||
const QPixmap hidpi = icon.pixmap(QSize(24, 24), 2.0);
|
||
ASSERT_FALSE(hidpi.isNull());
|
||
EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0);
|
||
EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(24, 24));
|
||
|
||
palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#abcdef")));
|
||
qApp->setPalette(palette);
|
||
const QColor secondInk = expectedNeutralInk(qApp->palette());
|
||
EXPECT_NE(firstInk, secondInk) << "the icon did not follow the palette";
|
||
const QPixmap flipped = icon.pixmap(QSize(24, 24), 1.0);
|
||
EXPECT_GT(countOpaquePixels(flipped, secondInk), 0);
|
||
|
||
QPalette selectedPalette = qApp->palette();
|
||
selectedPalette.setColor(QPalette::HighlightedText, QColor(QStringLiteral("#00ff00")));
|
||
qApp->setPalette(selectedPalette);
|
||
const QPixmap selected = icon.pixmap(QSize(24, 24), 1.0, QIcon::Selected, QIcon::Off);
|
||
EXPECT_GT(countOpaquePixels(selected, QColor(QStringLiteral("#00ff00"))), 0);
|
||
}
|
||
} // namespace
|
||
|
||
TEST_F(ThemeIconRenderTest, SentinelUsesPaletteColor)
|
||
{
|
||
QPalette palette;
|
||
palette.setColor(QPalette::Window, QColor(Qt::black));
|
||
palette.setColor(QPalette::WindowText, QColor(Qt::white));
|
||
qApp->setPalette(palette);
|
||
|
||
const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg"));
|
||
|
||
ASSERT_FALSE(pixmap.isNull());
|
||
EXPECT_EQ(pixmap.size(), QSize(24, 24));
|
||
EXPECT_GT(countOpaquePixels(pixmap, expectedNeutralInk(qApp->palette())), 0);
|
||
}
|
||
|
||
TEST_F(ThemeIconRenderTest, NonSentinelSvgReturnsNull)
|
||
{
|
||
QTemporaryDir themeDir;
|
||
ASSERT_TRUE(themeDir.isValid());
|
||
QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg")));
|
||
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
|
||
file.write(svgWith("#000000"));
|
||
file.close();
|
||
QDir::addSearchPath(QStringLiteral("theme"), themeDir.path());
|
||
|
||
EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("plain.svg")).isNull());
|
||
EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("icons/search.png")).isNull());
|
||
}
|
||
|
||
TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved)
|
||
{
|
||
QTemporaryDir themeDir;
|
||
ASSERT_TRUE(themeDir.isValid());
|
||
QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("lower.svg")));
|
||
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
|
||
file.write(svgWith("currentcolor"));
|
||
file.close();
|
||
|
||
QDir::addSearchPath(QStringLiteral("theme"), themeDir.path());
|
||
|
||
QPalette palette;
|
||
palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456")));
|
||
qApp->setPalette(palette);
|
||
|
||
const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("lower.svg"));
|
||
|
||
ASSERT_FALSE(pixmap.isNull());
|
||
EXPECT_GT(countOpaquePixels(pixmap, expectedNeutralInk(qApp->palette())), 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 glyph recedes past the quiet level, whatever the family.
|
||
*
|
||
* The palette's own disabled text is a fixed gray, so on the darkest themes it
|
||
* out-reads the quiet level and an enabled glyph looks more disabled than a
|
||
* disabled one. The disabled ink is derived from the quiet ink instead, and a
|
||
* semantic family loses its hue when disabled.
|
||
*/
|
||
TEST_F(ThemeIconRenderTest, DisabledGlyphRecedesPastTheQuietLevel)
|
||
{
|
||
const QColor quiet = themeManager->appColor(AppColor::IconSoft);
|
||
ASSERT_TRUE(quiet.isValid());
|
||
const QPalette palette = qApp->palette();
|
||
const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window),
|
||
palette.color(QPalette::Active, QPalette::Button), quiet);
|
||
const QColor expected = ThemeIconInk::disabledInk(quiet, surface);
|
||
|
||
for (const AppColor::Role role : {AppColor::Icon, AppColor::Positive, AppColor::Negative}) {
|
||
const QColor disabled = IconTint(role).resolve(QIcon::Disabled);
|
||
EXPECT_EQ(disabled, expected) << static_cast<int>(role);
|
||
EXPECT_NE(disabled, themeManager->appColor(role)) << static_cast<int>(role);
|
||
}
|
||
EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface) - ThemeIconInk::contrastRatio(expected, surface), 1.0);
|
||
}
|
||
|
||
/** @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);
|
||
// The normal ink is the accent-free neutral fallback: the platform text
|
||
// color held to the label floor. A text color that already clears the floor
|
||
// is the ink verbatim, so this pins the rule and the floor, not a shift.
|
||
const QColor normal = tint.resolve(QIcon::Normal);
|
||
EXPECT_EQ(normal, expectedNeutralInk(qApp->palette()));
|
||
const QPalette resolved = qApp->palette();
|
||
const QColor surface = ThemeIconInk::harderSurface(resolved.color(QPalette::Active, QPalette::Window),
|
||
resolved.color(QPalette::Active, QPalette::Button),
|
||
resolved.color(QPalette::Active, QPalette::WindowText));
|
||
EXPECT_GE(ThemeIconInk::contrastRatio(normal, surface), ThemeIconInk::labelContrastFloor());
|
||
// A selected row paints on the highlight, so the ink is the palette's
|
||
// highlighted text rather than the icon ink.
|
||
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 A line glyph renders the authored role verbatim.
|
||
*
|
||
* The emphasis colors are authored data now, so the paint path must not
|
||
* rewrite them. A muted quiet role stays muted on a line glyph, and the badge
|
||
* and sticker only shape the value of that tone to give the shape depth.
|
||
*/
|
||
TEST_F(ThemeIconRenderTest, LineTintRendersTheAuthoredRoleVerbatim)
|
||
{
|
||
const QColor raw = themeManager->appColor(AppColor::AccentSoft);
|
||
ASSERT_TRUE(raw.isValid());
|
||
ASSERT_LT(raw.hsvSaturation(), 170);
|
||
|
||
EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Normal), raw);
|
||
|
||
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, 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 authored tone darkened, and it must actually be painted.
|
||
EXPECT_GT(countOpaquePixels(pixmap, tone.darker(300)), 0);
|
||
|
||
// The fill runs from a lighter top to a darker bottom. Sample left of the
|
||
// check, which now reaches almost to the disc edge.
|
||
const int fillX = 10;
|
||
const QColor top = image.pixelColor(fillX, 16);
|
||
const QColor bottom = image.pixelColor(fillX, 48);
|
||
ASSERT_GT(top.alpha(), 200);
|
||
ASSERT_GT(bottom.alpha(), 200);
|
||
EXPECT_GT(top.lightness(), bottom.lightness());
|
||
|
||
// The glyph uses the contrast color chosen for this tone.
|
||
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 0);
|
||
}
|
||
|
||
/**
|
||
* @brief The dark-scheme disc keeps the authored tone and darkens it.
|
||
*
|
||
* The authored tone is the top of the gradient, so the level the palette author
|
||
* picked survives the treatment; the shape only adds depth by darkening the
|
||
* bottom. It must not re-saturate the tone: the authored saturation is the theme
|
||
* author's choice. The dark scheme keeps the darkened ink the badge had before
|
||
* the fill moved to the sticker ramp.
|
||
*/
|
||
TEST_F(ThemeIconRenderTest, BadgeDarkSchemeKeepsTheAuthoredTone)
|
||
{
|
||
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), ThemeIconFill::Scheme::Dark);
|
||
ASSERT_FALSE(pixmap.isNull());
|
||
const QImage image = pixmap.toImage();
|
||
|
||
const QColor top = image.pixelColor(10, 16);
|
||
const QColor bottom = image.pixelColor(10, 48);
|
||
ASSERT_GT(top.alpha(), 200);
|
||
ASSERT_GT(bottom.alpha(), 200);
|
||
// The top is a darkening of the authored tone, never a lift, and it keeps
|
||
// the authored saturation; the sample sits a little down the gradient.
|
||
EXPECT_LE(top.value(), tone.value());
|
||
EXPECT_GT(top.value(), bottom.value());
|
||
EXPECT_LT(bottom.value(), tone.value());
|
||
EXPECT_GT(top.lightness(), bottom.lightness());
|
||
// Antialiasing costs a point or two of saturation at the sample points.
|
||
EXPECT_NEAR(top.hsvSaturation(), tone.hsvSaturation(), 24);
|
||
EXPECT_NEAR(bottom.hsvSaturation(), tone.hsvSaturation(), 24);
|
||
|
||
// The dark scheme keeps the darkened ink, and it is actually painted.
|
||
const QColor ink = ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Dark);
|
||
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 lifted 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, testCase.dark ? ThemeIconFill::Scheme::Dark
|
||
: ThemeIconFill::Scheme::Light);
|
||
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, ThemeIconFill::Scheme::Light)), 8);
|
||
}
|
||
|
||
/** @brief The pixmap API's badge path agrees with the engine's. */
|
||
TEST_F(ThemeIconRenderTest, SentinelSvgBadgeMatchesTheEngine)
|
||
{
|
||
const QColor tone = themeManager->appColor(AppColor::Positive);
|
||
const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/ready_start.svg"), AppColor::Positive,
|
||
QSize(48, 48), IconStyle::Badge);
|
||
ASSERT_FALSE(pixmap.isNull());
|
||
EXPECT_EQ(pixmap.size(), QSize(48, 48));
|
||
EXPECT_GT(countOpaquePixels(pixmap, tone.darker(300)), 0);
|
||
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 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, tone.darker(300)), 0);
|
||
}
|
||
|
||
/** @brief Disabled keeps the shape language but drops back to the line glyph. */
|
||
TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph)
|
||
{
|
||
const QColor disabled = IconTint(AppColor::Icon).resolve(QIcon::Disabled);
|
||
ASSERT_TRUE(disabled.isValid());
|
||
|
||
const QColor tone = themeManager->appColor(AppColor::Positive);
|
||
const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge);
|
||
const QPixmap pixmap = icon.pixmap(QSize(64, 64), QIcon::Disabled);
|
||
ASSERT_FALSE(pixmap.isNull());
|
||
|
||
// A filled disc at full mass would not read as unavailable, so Disabled
|
||
// drops the badge: no ring, no disc, just the disabled line glyph.
|
||
EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0);
|
||
EXPECT_EQ(countOpaquePixels(pixmap, disabled.darker(300)), 0);
|
||
EXPECT_EQ(pixmap.toImage().pixelColor(0, 0).alpha(), 0);
|
||
EXPECT_GT(countOpaquePixels(pixmap, disabled), 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 keeps the authored tone and darkens it.
|
||
*
|
||
* The authored tone is the top of the gradient, so the level the palette author
|
||
* picked survives the treatment. The treatment only adds depth by darkening the
|
||
* bottom, without re-saturating the tone.
|
||
*/
|
||
TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillKeepsTheAuthoredTone)
|
||
{
|
||
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);
|
||
// The top is a darkening of the authored tone, never a lift, and it keeps
|
||
// the authored saturation; the sample sits a little down the gradient.
|
||
EXPECT_LE(top.value(), tone.value());
|
||
EXPECT_GT(top.value(), bottom.value());
|
||
EXPECT_LT(bottom.value(), tone.value());
|
||
EXPECT_GT(top.lightness(), bottom.lightness());
|
||
EXPECT_NEAR(top.hsvSaturation(), tone.hsvSaturation(), 24);
|
||
EXPECT_NEAR(bottom.hsvSaturation(), tone.hsvSaturation(), 24);
|
||
}
|
||
|
||
/** @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)
|
||
{
|
||
const QColor disabled = IconTint(AppColor::Icon).resolve(QIcon::Disabled);
|
||
ASSERT_TRUE(disabled.isValid());
|
||
|
||
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, disabled), 0);
|
||
}
|
||
|
||
/** @brief Line stays the default: no disc, no ring, just the recolored glyph. */
|
||
TEST_F(ThemeIconRenderTest, LineStyleStillRendersGlyphAlone)
|
||
{
|
||
const QColor tone = themeManager->appColor(AppColor::Positive);
|
||
const QPixmap pixmap =
|
||
loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive).pixmap(QSize(64, 64));
|
||
ASSERT_FALSE(pixmap.isNull());
|
||
|
||
EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0);
|
||
EXPECT_GT(countOpaquePixels(pixmap, tone), 0);
|
||
}
|
||
|
||
/**
|
||
* @brief Writes the 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 = renderAtDpr(
|
||
QSize(side, side), DPR, [&](const QSize &pixelSize) { return renderLine(svg, tone, pixelSize); });
|
||
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 renders an authored tone verbatim.
|
||
const QColor tone = 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 = renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) {
|
||
return sticker ? ThemeIconSticker::renderSticker(svg, tone, pixelSize)
|
||
: renderLine(svg, tone, pixelSize);
|
||
});
|
||
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), renderAtDpr(QSize(CONTEXT_SIZE, CONTEXT_SIZE), DPR, [&](const QSize &pixelSize) {
|
||
return ThemeIconBadge::renderBadge(
|
||
svg, tone, pixelSize, dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light);
|
||
}));
|
||
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 = 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);
|
||
const QPixmap pixmap = renderAtDpr(side, DPR, [&](const QSize &pixelSize) {
|
||
if (j == 0) {
|
||
return renderLine(svg, tone, pixelSize);
|
||
}
|
||
if (j == 1) {
|
||
return ThemeIconBadge::renderBadge(
|
||
svg, tone, pixelSize, dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light);
|
||
}
|
||
return ThemeIconSticker::renderSticker(svg, tone, pixelSize);
|
||
});
|
||
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 renders an authored tone verbatim.
|
||
const QColor tone = defaultAppColor(glyph.role, dark);
|
||
|
||
for (int row = 0; row < 2; ++row) {
|
||
const bool badge = row == 1;
|
||
const qreal rowTop = y - GLYPH_BLOCK_HEIGHT + 22 + row * ROW_HEIGHT;
|
||
painter.drawText(QPointF(MARGIN, rowTop + 16),
|
||
(badge ? QStringLiteral("badge ") : QStringLiteral("line ")) +
|
||
QString::fromLatin1(glyph.name));
|
||
for (std::size_t i = 0; i < std::size(SIZES); ++i) {
|
||
const int side = SIZES[i];
|
||
const QPixmap pixmap = renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) {
|
||
return badge ? ThemeIconBadge::renderBadge(svg, tone, pixelSize,
|
||
dark ? ThemeIconFill::Scheme::Dark
|
||
: ThemeIconFill::Scheme::Light)
|
||
: renderLine(svg, tone, pixelSize);
|
||
});
|
||
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();
|
||
}
|
||
}
|
||
|
||
namespace
|
||
{
|
||
/** @brief Palette roles the ink rule reads, from one shipped theme file. */
|
||
struct InkPalette
|
||
{
|
||
QColor text;
|
||
QColor buttonText;
|
||
QColor window;
|
||
QColor button;
|
||
QColor accent;
|
||
QMap<AppColor::Role, QColor> roles;
|
||
};
|
||
|
||
/**
|
||
* @brief Loads a shipped palette file, or returns a default-constructed palette.
|
||
*
|
||
* The theme files are the only place the shipped values live, so the rule is
|
||
* tested against them rather than against a copy of them.
|
||
*/
|
||
InkPalette shippedPalette(const QString &theme, bool dark)
|
||
{
|
||
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);
|
||
// A theme may author only [AppColors] (System does), so appColors alone do
|
||
// not mean it ships a palette: the keys have to be present. The authored
|
||
// neutral-family assertions therefore cover only themes with a palette;
|
||
// System deliberately leaves its neutral family unauthored, which
|
||
// ShippedPalettesAuthorTheEmphasisLadder pins, and its semantic fallbacks
|
||
// are measured against representative platform surfaces by
|
||
// DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces.
|
||
if (config.colors.isEmpty()) {
|
||
return {};
|
||
}
|
||
const QPalette palette = config.apply(QPalette());
|
||
return {
|
||
palette.color(QPalette::Active, QPalette::WindowText), palette.color(QPalette::Active, QPalette::ButtonText),
|
||
palette.color(QPalette::Active, QPalette::Window), palette.color(QPalette::Active, QPalette::Button),
|
||
palette.color(QPalette::Active, QPalette::Highlight), config.appColors};
|
||
}
|
||
|
||
/**
|
||
* @brief The [AppColors] a shipped theme authors, even when it ships no palette.
|
||
*
|
||
* System deliberately has no palette of its own, but it still authors its
|
||
* semantic roles. The neutral icon family stays unauthored there so it follows
|
||
* the live platform palette; see ShippedPalettesAuthorTheEmphasisLadder.
|
||
*/
|
||
QMap<AppColor::Role, QColor> shippedAppColors(const QString &theme, bool dark)
|
||
{
|
||
const QString file =
|
||
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR))
|
||
.absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml")
|
||
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||
return PaletteConfig::fromFile(file).appColors;
|
||
}
|
||
} // namespace
|
||
|
||
TEST(IconInk, GivesNeutralGlyphsAccentChroma)
|
||
{
|
||
const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), false);
|
||
ASSERT_TRUE(palette.text.isValid());
|
||
|
||
const QColor ink = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent);
|
||
EXPECT_NE(ink, palette.text) << "a glyph must not be painted with body-text ink verbatim";
|
||
// The borrowed accent is a lighter color than the near-black text ink, so
|
||
// the glyph gets a little lighter as well as more chromatic. What must not
|
||
// change is its legibility, its blue cast, and the fact that it reads as a
|
||
// richer color than plain text.
|
||
EXPECT_GT(ink.hsvSaturation(), palette.text.hsvSaturation()) << "the glyph gained no chroma";
|
||
EXPECT_GT(ink.blue(), ink.red()) << "accent chroma did not reach the glyph";
|
||
EXPECT_GE(ThemeIconInk::contrastRatio(ink, palette.window), ThemeIconInk::labelContrastFloor());
|
||
}
|
||
|
||
/**
|
||
* @brief A medium-light surface needs the pole that can actually reach the floor.
|
||
*
|
||
* #9a9a9a is lighter than mid-gray but still too light for white to reach a text
|
||
* ratio, so "light background means move toward black" is the wrong test.
|
||
*/
|
||
TEST(IconInk, ContrastFloorPicksThePoleItCanReach)
|
||
{
|
||
const QColor background(QStringLiteral("#9a9a9a"));
|
||
ASSERT_LT(ThemeIconInk::contrastRatio(QColor(Qt::white), background), 4.5);
|
||
ASSERT_GE(ThemeIconInk::contrastRatio(QColor(Qt::black), background), 4.5);
|
||
|
||
const QColor solved = ThemeIconInk::reachContrast(QColor(QStringLiteral("#d0d0d0")), background, 4.5);
|
||
EXPECT_GE(ThemeIconInk::contrastRatio(solved, background), 4.5);
|
||
}
|
||
|
||
/**
|
||
* @brief Primary stays louder than secondary on a light surface.
|
||
*
|
||
* A saturated blue or violet at full value is already dark enough to clear the
|
||
* label floor on a light surface. When solveValue() read the target as a
|
||
* minimum in both directions it rejected that tone, walked the whole saturation
|
||
* ladder and fell back to the unchanged secondary, so primary was never
|
||
* distinguishable. #5a3ee2 on #efeff4 is the shipped Plasma accent's shape.
|
||
*/
|
||
TEST(IconInk, PrimaryRaisesAChromaticToneOnALightSurface)
|
||
{
|
||
const QColor surface(QStringLiteral("#efeff4"));
|
||
const QColor violet(QStringLiteral("#5a3ee2"));
|
||
ASSERT_GE(ThemeIconInk::contrastRatio(violet, surface), ThemeIconInk::labelContrastFloor())
|
||
<< "the fixture must already be legible, so the fallback would be a no-op";
|
||
|
||
const QColor primary = ThemeIconInk::forEmphasis(violet, surface, IconEmphasis::Primary);
|
||
ASSERT_TRUE(primary.isValid());
|
||
EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::labelContrastFloor());
|
||
EXPECT_GT(primary.hsvSaturation(), violet.hsvSaturation()) << "primary fell back to the unchanged secondary";
|
||
EXPECT_NEAR(primary.hue(), violet.hue(), 2);
|
||
}
|
||
|
||
/**
|
||
* @brief A green or red accent must not tint neutral chrome.
|
||
*
|
||
* Positive owns green and Negative owns red, so a theme whose accent sits in
|
||
* either band would otherwise paint "settings" the same color as "add".
|
||
*/
|
||
TEST(IconInk, SemanticAccentDoesNotTintNeutralChrome)
|
||
{
|
||
const QColor surface(QStringLiteral("#18202e"));
|
||
const QColor text = Qt::white;
|
||
const QColor green(QStringLiteral("#149740"));
|
||
const QColor blue(QStringLiteral("#2f6fd6"));
|
||
|
||
ASSERT_TRUE(ThemeIconInk::isSemanticHue(green.hue()));
|
||
ASSERT_FALSE(ThemeIconInk::isSemanticHue(blue.hue()));
|
||
|
||
const QColor greenInk = ThemeIconInk::glyphInk(text, surface, green);
|
||
EXPECT_NEAR(greenInk.hue(), ThemeIconInk::neutralChromeHue(), 2);
|
||
EXPECT_FALSE(ThemeIconInk::isSemanticHue(greenInk.hue()));
|
||
EXPECT_NEAR(ThemeIconInk::glyphInk(text, surface, blue).hue(), blue.hue(), 1);
|
||
}
|
||
|
||
TEST(IconInk, MeetsTheLabelFloorOnEveryShippedPalette)
|
||
{
|
||
const QString themesRoot =
|
||
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes"));
|
||
const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||
ASSERT_FALSE(themes.isEmpty());
|
||
|
||
int checked = 0;
|
||
for (const QString &theme : themes) {
|
||
for (const bool dark : {false, true}) {
|
||
const InkPalette palette = shippedPalette(theme, dark);
|
||
if (!palette.text.isValid()) {
|
||
continue;
|
||
}
|
||
++checked;
|
||
const QColor ink = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent);
|
||
EXPECT_GE(ThemeIconInk::contrastRatio(ink, palette.window), ThemeIconInk::labelContrastFloor())
|
||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||
}
|
||
}
|
||
EXPECT_GT(checked, 0) << "no shipped palette was loadable";
|
||
}
|
||
|
||
TEST(IconInk, KeepsADeliberateHueWhenTheTextColorIsWeak)
|
||
{
|
||
// A theme whose text color is deliberately low-contrast still gets a glyph
|
||
// that clears the floor, and the hue survives the adjustment.
|
||
const QColor surface(QStringLiteral("#f4f4f4"));
|
||
const QColor weakText(QStringLiteral("#c9c9c9"));
|
||
const QColor ink = ThemeIconInk::glyphInk(weakText, surface, QColor(QStringLiteral("#c9c9c9")));
|
||
EXPECT_GE(ThemeIconInk::contrastRatio(ink, surface), ThemeIconInk::labelContrastFloor());
|
||
EXPECT_LT(ink.value(), weakText.value());
|
||
}
|
||
|
||
/**
|
||
* @brief Writes the glyph-ink comparison sheet when asked; skipped otherwise.
|
||
*
|
||
* One row per accent share, so the trade-off between "still neutral chrome" and
|
||
* "clearly an icon" can be judged against the body text drawn beside the glyph.
|
||
* Lives in the test binary so the colors come from the same rule the engine uses.
|
||
*/
|
||
TEST(IconInkPreview, 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));
|
||
|
||
constexpr const char *GLYPHS[] = {"search", "info", "settings", "filter", "increment"};
|
||
constexpr int SIZES[] = {15, 16, 24, 48};
|
||
constexpr int LAST_SIZE = SIZES[std::size(SIZES) - 1];
|
||
constexpr int CELL_GAP = 10;
|
||
|
||
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 = 20;
|
||
constexpr int SIZE_LABEL_WIDTH = 46;
|
||
constexpr int BODY_WIDTH = 46;
|
||
constexpr int COLUMN_GAP = 28;
|
||
constexpr int ROW_HEIGHT = 58;
|
||
constexpr qreal DPR = 2.0;
|
||
|
||
// The same five glyphs at the sizes the client actually draws them, so the
|
||
// comparison is one the reviewer can read rather than a color swatch.
|
||
const int glyphsWidth =
|
||
static_cast<int>(std::size(GLYPHS)) * LAST_SIZE + (static_cast<int>(std::size(GLYPHS)) - 1) * CELL_GAP;
|
||
const int oldX = MARGIN + SIZE_LABEL_WIDTH + BODY_WIDTH;
|
||
const int newX = oldX + glyphsWidth + COLUMN_GAP;
|
||
const int logicalWidth = newX + glyphsWidth + MARGIN;
|
||
const int blockHeight = 46 + static_cast<int>(std::size(SIZES)) * ROW_HEIGHT + 10;
|
||
const int logicalHeight = MARGIN + 24 + 20 + 26 + 2 * blockHeight + MARGIN;
|
||
|
||
for (const bool dark : {false, true}) {
|
||
const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark);
|
||
ASSERT_TRUE(palette.text.isValid());
|
||
|
||
QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)),
|
||
QImage::Format_ARGB32_Premultiplied);
|
||
image.fill(palette.window);
|
||
QPainter painter(&image);
|
||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||
painter.scale(DPR, DPR);
|
||
|
||
const QFont bodyFont(QStringLiteral("Sans"), 10);
|
||
const QFont labelFont(QStringLiteral("Sans"), 8);
|
||
const QFont captionFont(QStringLiteral("Sans"), 9, QFont::DemiBold);
|
||
|
||
qreal y = MARGIN;
|
||
painter.setPen(palette.text);
|
||
painter.setFont(QFont(QStringLiteral("Sans"), 11, QFont::DemiBold));
|
||
painter.drawText(QPointF(MARGIN, y), (dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")) +
|
||
QStringLiteral(" — neutral glyph ink"));
|
||
y += 18;
|
||
painter.setFont(labelFont);
|
||
painter.drawText(QPointF(MARGIN, y),
|
||
QStringLiteral("Body text is the same in both columns; only the glyph ink changes."));
|
||
y += 20;
|
||
|
||
// The two colors as filled swatches: at 15-24px the glyph difference is
|
||
// a hue shift, and the hex pair makes it measurable at a glance.
|
||
const QColor windowOld = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent, 0.0);
|
||
const QColor windowNew = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent);
|
||
qreal swatchX = MARGIN;
|
||
for (const auto &[ink, label] : {std::pair<QColor, QString>(windowOld, QStringLiteral("before")),
|
||
std::pair<QColor, QString>(windowNew, QStringLiteral("after"))}) {
|
||
painter.setPen(Qt::NoPen);
|
||
painter.setBrush(ink);
|
||
painter.drawRoundedRect(QRectF(swatchX, y - 10, 14, 14), 3, 3);
|
||
painter.setBrush(Qt::NoBrush);
|
||
painter.setPen(palette.text);
|
||
painter.drawText(QPointF(swatchX + 20, y), QStringLiteral("%1 %2").arg(label, ink.name(QColor::HexRgb)));
|
||
swatchX += 150;
|
||
}
|
||
y += 24;
|
||
|
||
for (const bool onButton : {false, true}) {
|
||
const QColor surface = onButton ? palette.button : palette.window;
|
||
const QColor oldInk = ThemeIconInk::glyphInk(palette.text, surface, palette.accent, 0.0);
|
||
const QColor newInk = ThemeIconInk::glyphInk(palette.text, surface, palette.accent);
|
||
|
||
const qreal blockTop = y;
|
||
if (onButton) {
|
||
painter.fillRect(QRectF(0, blockTop, logicalWidth, blockHeight), surface);
|
||
}
|
||
|
||
painter.setPen(palette.text);
|
||
painter.setFont(captionFont);
|
||
painter.drawText(QPointF(MARGIN, blockTop + 14),
|
||
onButton ? QStringLiteral("Button surface") : QStringLiteral("Window surface"));
|
||
painter.setFont(labelFont);
|
||
painter.drawText(QPointF(MARGIN, blockTop + 34), QStringLiteral("size"));
|
||
painter.drawText(QPointF(MARGIN + SIZE_LABEL_WIDTH, blockTop + 34), QStringLiteral("body"));
|
||
painter.drawText(QPointF(oldX, blockTop + 34), QStringLiteral("before: WindowText"));
|
||
painter.drawText(
|
||
QPointF(newX, blockTop + 34),
|
||
QStringLiteral("after: %1% accent ink").arg(qRound(ThemeIconInk::defaultAccentMix() * 100)));
|
||
|
||
qreal rowY = blockTop + 46;
|
||
for (const int side : SIZES) {
|
||
const qreal baseline = rowY + ROW_HEIGHT / 2.0 + 3.5;
|
||
painter.setFont(labelFont);
|
||
painter.drawText(QPointF(MARGIN, baseline), QStringLiteral("%1 px").arg(side));
|
||
painter.setFont(bodyFont);
|
||
painter.drawText(QPointF(MARGIN + SIZE_LABEL_WIDTH, baseline), QStringLiteral("Aa"));
|
||
|
||
for (std::size_t i = 0; i < std::size(GLYPHS); ++i) {
|
||
const QByteArray svg = readIconSvg(GLYPHS[i]);
|
||
ASSERT_FALSE(svg.isEmpty()) << GLYPHS[i];
|
||
const qreal cellOffset = static_cast<qreal>(i) * (side + CELL_GAP);
|
||
const qreal glyphY = rowY + (ROW_HEIGHT - side) / 2.0;
|
||
painter.drawPixmap(QPointF(oldX + cellOffset, glyphY),
|
||
renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) {
|
||
return renderLine(svg, oldInk, pixelSize);
|
||
}));
|
||
painter.drawPixmap(QPointF(newX + cellOffset, glyphY),
|
||
renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) {
|
||
return renderLine(svg, newInk, pixelSize);
|
||
}));
|
||
}
|
||
rowY += ROW_HEIGHT;
|
||
}
|
||
y = blockTop + blockHeight;
|
||
}
|
||
|
||
painter.end();
|
||
const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("ink-preview-dark.png")
|
||
: QStringLiteral("ink-preview-light.png"));
|
||
ASSERT_TRUE(image.save(path)) << path.toStdString();
|
||
}
|
||
}
|
||
|
||
namespace
|
||
{
|
||
/** @brief Applies a shipped palette file to the application for a test. */
|
||
bool applyShippedPalette(const QString &theme, bool dark)
|
||
{
|
||
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);
|
||
if (!config.hasPalette()) {
|
||
return false;
|
||
}
|
||
qApp->setPalette(config.apply(QPalette()));
|
||
return true;
|
||
}
|
||
} // namespace
|
||
|
||
/**
|
||
* @brief Emphasis selects the authored variant of a family, not a computed color.
|
||
*
|
||
* The theme stores one color per level, so the mapping is a lookup: a base role
|
||
* follows the caller's emphasis, while naming a variant pins that level at every
|
||
* emphasis. This is the contract that makes the palette editor's rows the whole
|
||
* source of truth.
|
||
*/
|
||
TEST_F(ThemeIconRenderTest, EmphasisSelectsTheAuthoredVariant)
|
||
{
|
||
EXPECT_EQ(IconTint().resolve(QIcon::Normal), themeManager->appColor(AppColor::Icon));
|
||
EXPECT_EQ(IconTint::primary().resolve(QIcon::Normal), themeManager->appColor(AppColor::IconStrong));
|
||
EXPECT_EQ(IconTint::quiet().resolve(QIcon::Normal), themeManager->appColor(AppColor::IconSoft));
|
||
EXPECT_EQ(IconTint(QPalette::WindowText, IconEmphasis::Primary).resolve(QIcon::Normal),
|
||
themeManager->appColor(AppColor::IconStrong));
|
||
|
||
struct Family
|
||
{
|
||
AppColor::Role base;
|
||
AppColor::Role strong;
|
||
AppColor::Role soft;
|
||
};
|
||
|
||
for (const Family &family :
|
||
{Family{AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft},
|
||
Family{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft},
|
||
Family{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft},
|
||
Family{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft},
|
||
Family{AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft},
|
||
Family{AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft},
|
||
Family{AppColor::Movement, AppColor::MovementStrong, AppColor::MovementSoft}}) {
|
||
EXPECT_EQ(IconTint(family.base, IconEmphasis::Primary).resolve(QIcon::Normal),
|
||
themeManager->appColor(family.strong));
|
||
EXPECT_EQ(IconTint(family.base).resolve(QIcon::Normal), themeManager->appColor(family.base));
|
||
EXPECT_EQ(IconTint(family.base, IconEmphasis::Quiet).resolve(QIcon::Normal),
|
||
themeManager->appColor(family.soft));
|
||
// Naming a variant is explicit: emphasis does not rewrite it.
|
||
EXPECT_EQ(IconTint(family.strong, IconEmphasis::Quiet).resolve(QIcon::Normal),
|
||
themeManager->appColor(family.strong));
|
||
EXPECT_EQ(IconTint(family.soft, IconEmphasis::Primary).resolve(QIcon::Normal),
|
||
themeManager->appColor(family.soft));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @brief The authored primary level is the most chromatic member of its family.
|
||
*
|
||
* Contrast cannot order the ladder: on a dark surface the highest-contrast tone
|
||
* is the palest one, so a contrast target makes the primary read as a washed-out
|
||
* secondary. Chroma orders the levels instead; contrast only has to stay above
|
||
* the legibility floor on every surface the icon is drawn on.
|
||
*/
|
||
TEST_F(ThemeIconRenderTest, PrimaryIsTheMostChromaticLevelOnBothSchemes)
|
||
{
|
||
for (const bool dark : {false, true}) {
|
||
const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark);
|
||
ASSERT_TRUE(palette.text.isValid()) << (dark ? "dark" : "light");
|
||
const QColor secondary = palette.roles.value(AppColor::Icon);
|
||
const QColor primary = palette.roles.value(AppColor::IconStrong);
|
||
ASSERT_TRUE(secondary.isValid() && primary.isValid()) << (dark ? "dark" : "light");
|
||
|
||
EXPECT_GT(primary.hsvSaturation(), secondary.hsvSaturation()) << (dark ? "dark" : "light");
|
||
for (const QColor &surface : {palette.window, palette.button}) {
|
||
EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor())
|
||
<< (dark ? "dark" : "light");
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @brief Quiet is the least chromatic level; it shares the legibility floor.
|
||
*
|
||
* Ordering the ladder by contrast inverted it on dark schemes and pushed quiet
|
||
* under the disabled ink. Quiet now keeps the same floor as the other levels and
|
||
* recedes by carrying less of the family's chroma, which also leaves the
|
||
* disabled level the only one below the floor.
|
||
*/
|
||
TEST_F(ThemeIconRenderTest, QuietIsTheLeastChromaticLevel)
|
||
{
|
||
for (const bool dark : {false, true}) {
|
||
const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark);
|
||
ASSERT_TRUE(palette.text.isValid()) << (dark ? "dark" : "light");
|
||
const QColor secondary = palette.roles.value(AppColor::Icon);
|
||
const QColor quiet = palette.roles.value(AppColor::IconSoft);
|
||
ASSERT_TRUE(secondary.isValid() && quiet.isValid()) << (dark ? "dark" : "light");
|
||
|
||
EXPECT_LT(quiet.hsvSaturation(), secondary.hsvSaturation()) << (dark ? "dark" : "light");
|
||
for (const QColor &surface : {palette.window, palette.button}) {
|
||
const double quietContrast = ThemeIconInk::contrastRatio(quiet, surface);
|
||
EXPECT_GE(quietContrast, ThemeIconInk::quietContrastFloor()) << (dark ? "dark" : "light");
|
||
EXPECT_GE(quietContrast - ThemeIconInk::contrastRatio(ThemeIconInk::disabledInk(quiet, surface), surface),
|
||
1.0)
|
||
<< (dark ? "dark" : "light");
|
||
}
|
||
}
|
||
}
|
||
|
||
/** @brief Every authored emphasis level keeps a semantic family's hue. */
|
||
TEST_F(ThemeIconRenderTest, EmphasisKeepsASemanticHue)
|
||
{
|
||
const QString themesRoot =
|
||
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes"));
|
||
const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||
ASSERT_FALSE(themes.isEmpty());
|
||
|
||
int checked = 0;
|
||
for (const QString &theme : themes) {
|
||
for (const bool dark : {false, true}) {
|
||
const QMap<AppColor::Role, QColor> roles = shippedAppColors(theme, dark);
|
||
|
||
for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}) {
|
||
const QColor ink = roles.value(role);
|
||
if (!ink.isValid()) {
|
||
continue;
|
||
}
|
||
++checked;
|
||
EXPECT_GT(ink.green(), ink.red()) << theme.toStdString() << static_cast<int>(role);
|
||
EXPECT_GT(ink.green(), ink.blue()) << theme.toStdString() << static_cast<int>(role);
|
||
}
|
||
|
||
for (const AppColor::Role role : {AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) {
|
||
const QColor ink = roles.value(role);
|
||
if (!ink.isValid()) {
|
||
continue;
|
||
}
|
||
++checked;
|
||
EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast<int>(role);
|
||
EXPECT_GT(ink.red(), ink.blue()) << theme.toStdString() << static_cast<int>(role);
|
||
}
|
||
|
||
for (const AppColor::Role role :
|
||
{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}) {
|
||
// Informational may come from the scheme default when the theme
|
||
// does not author it, so measure the value the client paints.
|
||
const QColor ink = roles.value(role, defaultAppColor(role, dark));
|
||
if (!ink.isValid()) {
|
||
continue;
|
||
}
|
||
++checked;
|
||
EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast<int>(role);
|
||
EXPECT_GT(ink.blue(), ink.green()) << theme.toStdString() << static_cast<int>(role);
|
||
}
|
||
|
||
for (const AppColor::Role role : {AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft}) {
|
||
// Hidden is violet: blue leads, then red, then green.
|
||
const QColor ink = roles.value(role, defaultAppColor(role, dark));
|
||
if (!ink.isValid()) {
|
||
continue;
|
||
}
|
||
++checked;
|
||
EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast<int>(role);
|
||
EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast<int>(role);
|
||
}
|
||
|
||
for (const AppColor::Role role : {AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft}) {
|
||
// Marked is amber: red leads, then green, then blue.
|
||
const QColor ink = roles.value(role, defaultAppColor(role, dark));
|
||
if (!ink.isValid()) {
|
||
continue;
|
||
}
|
||
++checked;
|
||
EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast<int>(role);
|
||
EXPECT_GT(ink.green(), ink.blue()) << theme.toStdString() << static_cast<int>(role);
|
||
}
|
||
|
||
for (const AppColor::Role role : {AppColor::Movement, AppColor::MovementStrong, AppColor::MovementSoft}) {
|
||
// Movement is teal: green leads, then blue, then red.
|
||
const QColor ink = roles.value(role, defaultAppColor(role, dark));
|
||
if (!ink.isValid()) {
|
||
continue;
|
||
}
|
||
++checked;
|
||
EXPECT_GT(ink.green(), ink.blue()) << theme.toStdString() << static_cast<int>(role);
|
||
EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast<int>(role);
|
||
}
|
||
}
|
||
}
|
||
EXPECT_GT(checked, 0) << "no shipped theme authored a semantic emphasis level";
|
||
}
|
||
|
||
/**
|
||
* @brief Every palette-shipping theme authors the whole emphasis ladder.
|
||
*
|
||
* The palette editor treats an unset role as "derive it", so a missing key is
|
||
* not a crash: it silently reintroduces the computed colors this change exists
|
||
* to remove. Treat a missing role as a test failure instead, and keep the same
|
||
* contrast contract the old computed levels had.
|
||
*/
|
||
TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder)
|
||
{
|
||
const QString themesRoot =
|
||
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes"));
|
||
const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||
ASSERT_FALSE(themes.isEmpty());
|
||
|
||
int checked = 0;
|
||
for (const QString &theme : themes) {
|
||
for (const bool dark : {false, true}) {
|
||
const InkPalette palette = shippedPalette(theme, dark);
|
||
if (!palette.text.isValid()) {
|
||
continue; // System ships no palette, so it has no surfaces to measure.
|
||
}
|
||
++checked;
|
||
|
||
for (const AppColor::Role role : {AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft,
|
||
AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft,
|
||
AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) {
|
||
ASSERT_TRUE(palette.roles.contains(role))
|
||
<< theme.toStdString() << (dark ? " dark" : " light") << static_cast<int>(role);
|
||
}
|
||
|
||
for (const QColor &surface : {palette.window, palette.button}) {
|
||
const double secondary = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::Icon), surface);
|
||
const double primary = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::IconStrong), surface);
|
||
const double quiet = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::IconSoft), surface);
|
||
EXPECT_GE(secondary, ThemeIconInk::labelContrastFloor())
|
||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||
EXPECT_GE(primary, ThemeIconInk::primaryContrastFloor())
|
||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||
EXPECT_GT(palette.roles.value(AppColor::IconStrong).hsvSaturation(),
|
||
palette.roles.value(AppColor::Icon).hsvSaturation())
|
||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||
EXPECT_GE(quiet, ThemeIconInk::quietContrastFloor())
|
||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||
EXPECT_LT(palette.roles.value(AppColor::IconSoft).hsvSaturation(),
|
||
palette.roles.value(AppColor::Icon).hsvSaturation())
|
||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||
|
||
for (const auto &[base, strong, soft] :
|
||
{std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft},
|
||
std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) {
|
||
EXPECT_GE(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface),
|
||
ThemeIconInk::quietContrastFloor())
|
||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||
EXPECT_LT(palette.roles.value(soft).hsvSaturation(), palette.roles.value(base).hsvSaturation())
|
||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||
EXPECT_GT(palette.roles.value(strong).hsvSaturation(), palette.roles.value(base).hsvSaturation())
|
||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
EXPECT_GT(checked, 0) << "no shipped palette was loadable";
|
||
|
||
// The System theme ships no palette of its own, so its families have no
|
||
// authored surfaces to measure against. It nevertheless authors the
|
||
// semantic family, while the neutral family stays unauthored on purpose:
|
||
// the palette file is chosen from the theme's scheme, which is always light
|
||
// on Qt < 6.5 and can be unknown on newer Qt, so one fixed ladder would be
|
||
// invisible against a platform palette of the other scheme.
|
||
for (const bool dark : {false, true}) {
|
||
const QMap<AppColor::Role, QColor> system = shippedAppColors(QStringLiteral("System"), dark);
|
||
EXPECT_TRUE(system.contains(AppColor::PositiveStrong));
|
||
EXPECT_TRUE(system.contains(AppColor::NegativeSoft));
|
||
for (const AppColor::Role role : {AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}) {
|
||
EXPECT_FALSE(system.contains(role)) << (dark ? "dark" : "light") << static_cast<int>(role);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @brief Neutral chrome keeps a hue distance from the semantic families.
|
||
*
|
||
* Fusion's accent is green, so deriving neutral ink from it painted every menu
|
||
* and toolbar glyph the same color as the Positive family. The authored neutral
|
||
* family has to stay a hue a reader can tell apart from "add" and "remove".
|
||
*/
|
||
TEST(IconInk, NeutralFamilyKeepsItsDistanceFromTheSemanticFamilies)
|
||
{
|
||
const QString themesRoot =
|
||
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes"));
|
||
const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||
ASSERT_FALSE(themes.isEmpty());
|
||
|
||
int checked = 0;
|
||
for (const QString &theme : themes) {
|
||
for (const bool dark : {false, true}) {
|
||
const InkPalette palette = shippedPalette(theme, dark);
|
||
if (!palette.text.isValid()) {
|
||
continue;
|
||
}
|
||
const QColor neutral = palette.roles.value(AppColor::Icon);
|
||
for (const AppColor::Role role : {AppColor::Positive, AppColor::Negative}) {
|
||
const QColor semantic = palette.roles.value(role);
|
||
ASSERT_TRUE(neutral.isValid() && semantic.isValid())
|
||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||
const int distance = qAbs(neutral.hue() - semantic.hue());
|
||
EXPECT_GE(qMin(distance, 360 - distance), 30)
|
||
<< theme.toStdString() << (dark ? " dark" : " light") << static_cast<int>(role);
|
||
++checked;
|
||
}
|
||
}
|
||
}
|
||
EXPECT_GT(checked, 0) << "no shipped palette was loadable";
|
||
}
|
||
|
||
/**
|
||
* @brief Every shipped theme keeps the disabled level below the quiet level.
|
||
*
|
||
* The palette's disabled text is a fixed gray, so this is the invariant the
|
||
* derived disabled ink exists to hold; a regression here makes an enabled quiet
|
||
* glyph look unavailable.
|
||
*/
|
||
TEST(IconInk, DisabledStaysBelowQuietOnEveryShippedTheme)
|
||
{
|
||
const QString themesRoot =
|
||
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes"));
|
||
const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||
ASSERT_FALSE(themes.isEmpty());
|
||
|
||
int checked = 0;
|
||
for (const QString &theme : themes) {
|
||
for (const bool dark : {false, true}) {
|
||
const InkPalette palette = shippedPalette(theme, dark);
|
||
if (!palette.text.isValid()) {
|
||
continue;
|
||
}
|
||
const QColor quiet = palette.roles.value(AppColor::IconSoft);
|
||
ASSERT_TRUE(quiet.isValid()) << theme.toStdString() << (dark ? " dark" : " light");
|
||
const QColor surface = ThemeIconInk::harderSurface(palette.window, palette.button, quiet);
|
||
const QColor disabled = ThemeIconInk::disabledInk(quiet, surface);
|
||
EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface) - ThemeIconInk::contrastRatio(disabled, surface), 1.0)
|
||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||
|
||
for (const QColor &anySurface : {palette.window, palette.button}) {
|
||
EXPECT_LT(ThemeIconInk::contrastRatio(disabled, anySurface),
|
||
ThemeIconInk::contrastRatio(quiet, anySurface))
|
||
<< theme.toStdString() << (dark ? " dark" : " light");
|
||
}
|
||
++checked;
|
||
}
|
||
}
|
||
EXPECT_GT(checked, 0) << "no shipped palette was loadable";
|
||
}
|
||
|
||
/**
|
||
* @brief The System fallback family keeps the ladder on a platform palette.
|
||
*
|
||
* System authors no palette and follows the OS surfaces, so its families
|
||
* cannot be measured against its own window and button. These are the
|
||
* representative extremes of a platform palette, and the fixed values have to
|
||
* clear the same floor there that the five authored themes clear on their own
|
||
* surfaces.
|
||
*/
|
||
TEST(IconInk, DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces)
|
||
{
|
||
struct Scheme
|
||
{
|
||
bool dark;
|
||
QColor window;
|
||
QColor button;
|
||
};
|
||
const QList<Scheme> schemes = {
|
||
{false, QColor(QStringLiteral("#f0f0f0")), QColor(QStringLiteral("#f5f5f5"))},
|
||
{true, QColor(QStringLiteral("#2b2b2b")), QColor(QStringLiteral("#3c3c3c"))},
|
||
};
|
||
|
||
for (const Scheme &scheme : schemes) {
|
||
for (const auto &[base, strong, soft] :
|
||
{std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft},
|
||
std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft},
|
||
std::tuple{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft},
|
||
std::tuple{AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft},
|
||
std::tuple{AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft},
|
||
std::tuple{AppColor::Movement, AppColor::MovementStrong, AppColor::MovementSoft}}) {
|
||
const QColor secondary = defaultAppColor(base, scheme.dark);
|
||
const QColor primary = defaultAppColor(strong, scheme.dark);
|
||
const QColor quiet = defaultAppColor(soft, scheme.dark);
|
||
ASSERT_TRUE(secondary.isValid() && primary.isValid() && quiet.isValid());
|
||
|
||
for (const QColor &surface : {scheme.window, scheme.button}) {
|
||
EXPECT_GE(ThemeIconInk::contrastRatio(secondary, surface), ThemeIconInk::labelContrastFloor());
|
||
EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::labelContrastFloor());
|
||
EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface), ThemeIconInk::labelContrastFloor());
|
||
}
|
||
EXPECT_LT(quiet.hsvSaturation(), secondary.hsvSaturation());
|
||
EXPECT_GT(primary.hsvSaturation(), secondary.hsvSaturation());
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @brief Every emphasis keeps the selected-row swap to HighlightedText.
|
||
*/
|
||
TEST_F(ThemeIconRenderTest, SelectedStillFallsBackToHighlightedText)
|
||
{
|
||
const QColor tone = IconTint(AppColor::AccentSoft).resolve(QIcon::Normal);
|
||
ASSERT_TRUE(tone.isValid());
|
||
|
||
QPalette palette = qApp->palette();
|
||
const QColor selectedText(QStringLiteral("#222222"));
|
||
palette.setColor(QPalette::Active, QPalette::Highlight, tone);
|
||
palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText);
|
||
qApp->setPalette(palette);
|
||
|
||
// Tone on a highlight of the same color is illegible, so the selection wins
|
||
// for both the default and an explicitly tinted glyph.
|
||
EXPECT_EQ(IconTint().resolve(QIcon::Selected), selectedText);
|
||
EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Selected), selectedText);
|
||
}
|
||
|
||
/**
|
||
* @brief Writes the emphasis ladder sheets for every shipped palette theme.
|
||
*
|
||
* One sheet per theme and scheme: the neutral, positive and negative families at
|
||
* their quiet, secondary and primary levels, each as a line glyph, a sticker and
|
||
* a badge, three glyphs per family, at 16 and 24 px, on the window and button
|
||
* surfaces. The tone is the one the emphasis rule resolves for that surface, so
|
||
* the sheet is what the engine paints rather than a hand-picked sample.
|
||
*/
|
||
TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested)
|
||
{
|
||
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));
|
||
|
||
constexpr int MARGIN = 24;
|
||
constexpr int LABEL_WIDTH = 230;
|
||
constexpr int CELL_WIDTH = 166;
|
||
constexpr int ROW_HEIGHT = 58;
|
||
constexpr int PANEL_TITLE = 26;
|
||
constexpr int COLUMN_HEADER = 20;
|
||
constexpr int PANEL_PADDING = 12;
|
||
constexpr int GLYPH_SLOT = 32;
|
||
constexpr qreal DPR = 2.0;
|
||
constexpr int SIZES[] = {16, 24};
|
||
constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"};
|
||
constexpr const char *LEVEL_NAMES[] = {"Quiet", "Secondary", "Primary"};
|
||
|
||
struct Family
|
||
{
|
||
const char *name;
|
||
const char *glyphs[3];
|
||
AppColor::Role quiet;
|
||
AppColor::Role secondary;
|
||
AppColor::Role primary;
|
||
};
|
||
const QList<Family> families = {
|
||
{"Neutral", {"settings", "info", "player"}, AppColor::IconSoft, AppColor::Icon, AppColor::IconStrong},
|
||
{"Positive",
|
||
{"increment", "ready_start", "arrow_up"},
|
||
AppColor::PositiveSoft,
|
||
AppColor::Positive,
|
||
AppColor::PositiveStrong},
|
||
{"Negative",
|
||
{"decrement", "not_ready_start", "remove_row"},
|
||
AppColor::NegativeSoft,
|
||
AppColor::Negative,
|
||
AppColor::NegativeStrong},
|
||
{"Informational",
|
||
{"arrow_right", "view", "arrow_to_top"},
|
||
AppColor::InformationalSoft,
|
||
AppColor::Informational,
|
||
AppColor::InformationalStrong},
|
||
{"Hidden", {"view", "arrow_right", "swap"}, AppColor::HiddenSoft, AppColor::Hidden, AppColor::HiddenStrong},
|
||
{"Marked", {"pencil", "share", "pin"}, AppColor::MarkedSoft, AppColor::Marked, AppColor::MarkedStrong},
|
||
{"Movement",
|
||
{"arrow_to_top", "arrow_to_bottom", "arrow_down"},
|
||
AppColor::MovementSoft,
|
||
AppColor::Movement,
|
||
AppColor::MovementStrong},
|
||
};
|
||
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 panelWidth = LABEL_WIDTH + static_cast<int>(std::size(TREATMENTS)) * CELL_WIDTH + PANEL_PADDING;
|
||
const int panelHeight =
|
||
PANEL_TITLE + COLUMN_HEADER + static_cast<int>(families.size()) * 3 * 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 InkPalette palette = shippedPalette(theme, dark);
|
||
ASSERT_TRUE(palette.text.isValid()) << theme.toStdString();
|
||
|
||
// The emphasis rule reads the live palette, so apply the theme
|
||
// before resolving tones for the sheet.
|
||
ASSERT_TRUE(applyShippedPalette(theme, dark));
|
||
|
||
// The paint path is a lookup now, so the sheet shows the authored
|
||
// rows verbatim rather than a color recomputed from the accent.
|
||
const auto toneFor = [&](const Family &family, int level) {
|
||
const AppColor::Role role = level == 0 ? family.quiet : level == 1 ? family.secondary : family.primary;
|
||
// A theme written before the role existed does not author it;
|
||
// the client paints the scheme default, so the sheet does too.
|
||
return palette.roles.value(role, defaultAppColor(role, dark));
|
||
};
|
||
const auto scheme = dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light;
|
||
|
||
QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)),
|
||
QImage::Format_ARGB32_Premultiplied);
|
||
image.fill(palette.window);
|
||
QPainter painter(&image);
|
||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||
painter.scale(DPR, DPR);
|
||
|
||
painter.setPen(palette.text);
|
||
painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold));
|
||
painter.drawText(QPointF(MARGIN, MARGIN),
|
||
QStringLiteral("%1 — %2 scheme, emphasis across treatments")
|
||
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||
|
||
struct Surface
|
||
{
|
||
const char *name;
|
||
QColor background;
|
||
QColor text;
|
||
};
|
||
const QList<Surface> surfaces = {
|
||
{"Window surface", palette.window, palette.text},
|
||
{"Button surface", palette.button, palette.text},
|
||
};
|
||
|
||
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(surface.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 treatment = 0; treatment < std::size(TREATMENTS); ++treatment) {
|
||
const qreal x = contentLeft + LABEL_WIDTH + static_cast<qreal>(treatment) * CELL_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) {
|
||
for (int level = 0; level < 3; ++level) {
|
||
const QColor ink = toneFor(family, level);
|
||
ASSERT_TRUE(ink.isValid())
|
||
<< theme.toStdString() << " " << family.name << " " << LEVEL_NAMES[level];
|
||
|
||
painter.setPen(surface.text);
|
||
painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold));
|
||
painter.drawText(QPointF(contentLeft, y + 14),
|
||
QStringLiteral("%1 / %2").arg(QString::fromLatin1(family.name),
|
||
QString::fromLatin1(LEVEL_NAMES[level])));
|
||
painter.setFont(QFont(QStringLiteral("Mono"), 7));
|
||
painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb));
|
||
|
||
for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) {
|
||
const qreal cellX = contentLeft + LABEL_WIDTH + static_cast<qreal>(treatment) * CELL_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 (std::size_t glyphIndex = 0; glyphIndex < std::size(family.glyphs); ++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 (treatment == 0) {
|
||
return renderLine(svg, ink, pixelSize);
|
||
}
|
||
if (treatment == 1) {
|
||
return ThemeIconSticker::renderSticker(svg, ink, pixelSize);
|
||
}
|
||
return ThemeIconBadge::renderBadge(svg, ink, pixelSize, scheme);
|
||
});
|
||
const qreal x = cellX + 6 + static_cast<qreal>(glyphIndex) * GLYPH_SLOT +
|
||
(GLYPH_SLOT - side) / 2.0;
|
||
painter.drawPixmap(QPointF(x, rowTop + (GLYPH_SLOT - side) / 2.0), pixmap);
|
||
}
|
||
}
|
||
}
|
||
y += ROW_HEIGHT;
|
||
}
|
||
}
|
||
panelTop += panelHeight + 24;
|
||
}
|
||
|
||
painter.end();
|
||
const QString path = QDir(outDir).absoluteFilePath(
|
||
QStringLiteral("emphasis-preview-%1-%2.png")
|
||
.arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||
ASSERT_TRUE(image.save(path)) << path.toStdString();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @brief Writes the authored emphasis sheets for every shipped palette theme.
|
||
*
|
||
* The ladder is only useful if it survives the treatment a call site chooses, so
|
||
* each sheet renders the same three authored levels as line glyphs, as sticker
|
||
* fills and as badges, at the two sizes the UI uses, on the two surfaces the
|
||
* contrast targets are measured against. Each family carries three glyphs, so
|
||
* the sheet also proves the level, not just one lucky shape. A disabled row
|
||
* closes each family: it must read dimmer than quiet, and every treatment falls
|
||
* back to the line glyph there.
|
||
*
|
||
* The authored tone is the top of every filled gradient, so the sheet prints that
|
||
* tone and the darker bottom the shape actually carries.
|
||
*/
|
||
TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested)
|
||
{
|
||
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));
|
||
|
||
constexpr int MARGIN = 24;
|
||
constexpr int LABEL_WIDTH = 230;
|
||
constexpr int CELL_WIDTH = 166;
|
||
constexpr int ROW_HEIGHT = 58;
|
||
constexpr int PANEL_TITLE = 26;
|
||
constexpr int COLUMN_HEADER = 20;
|
||
constexpr int PANEL_PADDING = 12;
|
||
constexpr int GLYPH_SLOT = 32;
|
||
constexpr qreal DPR = 2.0;
|
||
constexpr int SIZES[] = {16, 24};
|
||
constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"};
|
||
constexpr const char *LEVEL_NAMES[] = {"Quiet", "Secondary", "Primary"};
|
||
|
||
struct Family
|
||
{
|
||
const char *name;
|
||
const char *glyphs[3];
|
||
AppColor::Role quiet;
|
||
AppColor::Role secondary;
|
||
AppColor::Role primary;
|
||
};
|
||
const QList<Family> families = {
|
||
{"Neutral", {"settings", "info", "player"}, AppColor::IconSoft, AppColor::Icon, AppColor::IconStrong},
|
||
{"Positive",
|
||
{"increment", "ready_start", "arrow_up"},
|
||
AppColor::PositiveSoft,
|
||
AppColor::Positive,
|
||
AppColor::PositiveStrong},
|
||
{"Negative",
|
||
{"decrement", "not_ready_start", "remove_row"},
|
||
AppColor::NegativeSoft,
|
||
AppColor::Negative,
|
||
AppColor::NegativeStrong},
|
||
{"Informational",
|
||
{"arrow_right", "view", "arrow_to_top"},
|
||
AppColor::InformationalSoft,
|
||
AppColor::Informational,
|
||
AppColor::InformationalStrong},
|
||
{"Hidden", {"view", "arrow_right", "swap"}, AppColor::HiddenSoft, AppColor::Hidden, AppColor::HiddenStrong},
|
||
{"Marked", {"pencil", "share", "pin"}, AppColor::MarkedSoft, AppColor::Marked, AppColor::MarkedStrong},
|
||
{"Movement",
|
||
{"arrow_to_top", "arrow_to_bottom", "arrow_down"},
|
||
AppColor::MovementSoft,
|
||
AppColor::Movement,
|
||
AppColor::MovementStrong},
|
||
};
|
||
const QStringList themes = {
|
||
QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"),
|
||
QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"),
|
||
};
|
||
|
||
const auto roleAt = [](const Family &family, int level) {
|
||
return level == 0 ? family.quiet : level == 1 ? family.secondary : family.primary;
|
||
};
|
||
const auto schemeFor = [](bool dark) { return dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; };
|
||
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 panelWidth = LABEL_WIDTH + static_cast<int>(std::size(TREATMENTS)) * CELL_WIDTH + PANEL_PADDING;
|
||
const int panelHeight =
|
||
PANEL_TITLE + COLUMN_HEADER + static_cast<int>(families.size()) * 4 * 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 InkPalette palette = shippedPalette(theme, dark);
|
||
ASSERT_TRUE(palette.text.isValid()) << theme.toStdString();
|
||
const QColor quietTone = palette.roles.value(AppColor::IconSoft);
|
||
ASSERT_TRUE(quietTone.isValid()) << theme.toStdString();
|
||
// What the engine paints for `QIcon::Disabled`: the authored quiet
|
||
// level receded past its floor, measured on the harder surface.
|
||
const QColor disabledTone = ThemeIconInk::disabledInk(
|
||
quietTone, ThemeIconInk::harderSurface(palette.window, palette.button, quietTone));
|
||
|
||
QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)),
|
||
QImage::Format_ARGB32_Premultiplied);
|
||
image.fill(palette.window);
|
||
QPainter painter(&image);
|
||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||
painter.scale(DPR, DPR);
|
||
|
||
painter.setPen(palette.text);
|
||
painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold));
|
||
painter.drawText(QPointF(MARGIN, MARGIN),
|
||
QStringLiteral("%1 — %2 scheme, authored emphasis across treatments")
|
||
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||
|
||
struct Surface
|
||
{
|
||
const char *name;
|
||
QColor background;
|
||
QColor text;
|
||
};
|
||
const QList<Surface> surfaces = {
|
||
{"Window surface", palette.window, palette.text},
|
||
{"Button surface", palette.button, palette.buttonText},
|
||
};
|
||
|
||
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(surface.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 treatment = 0; treatment < std::size(TREATMENTS); ++treatment) {
|
||
const qreal x = contentLeft + LABEL_WIDTH + static_cast<qreal>(treatment) * CELL_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) {
|
||
for (int level = 0; level < 4; ++level) {
|
||
const bool disabled = level == 3;
|
||
const QColor ink = disabled ? disabledTone
|
||
: palette.roles.value(roleAt(family, level),
|
||
defaultAppColor(roleAt(family, level), dark));
|
||
const QString levelName =
|
||
disabled ? QStringLiteral("Disabled") : QString::fromLatin1(LEVEL_NAMES[level]);
|
||
ASSERT_TRUE(ink.isValid())
|
||
<< theme.toStdString() << " " << family.name << " " << levelName.toStdString();
|
||
const QColor bottom = disabled ? ink : ThemeIconFill::bottomColor(ink);
|
||
|
||
painter.setPen(surface.text);
|
||
painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold));
|
||
painter.drawText(QPointF(contentLeft, y + 14),
|
||
QStringLiteral("%1 / %2").arg(QString::fromLatin1(family.name), levelName));
|
||
painter.setFont(QFont(QStringLiteral("Mono"), 7));
|
||
painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb));
|
||
if (!disabled && bottom != ink) {
|
||
painter.drawText(QPointF(contentLeft + 82, y + 28),
|
||
QStringLiteral("bottom ") + bottom.name(QColor::HexRgb));
|
||
}
|
||
|
||
for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) {
|
||
const qreal cellX = contentLeft + LABEL_WIDTH + static_cast<qreal>(treatment) * CELL_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 (std::size_t glyphIndex = 0; glyphIndex < std::size(family.glyphs); ++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 (disabled || treatment == 0) {
|
||
return renderLine(svg, ink, pixelSize);
|
||
}
|
||
if (treatment == 1) {
|
||
return ThemeIconSticker::renderSticker(svg, ink, pixelSize);
|
||
}
|
||
return ThemeIconBadge::renderBadge(svg, ink, pixelSize, schemeFor(dark));
|
||
});
|
||
const qreal x = cellX + 6 + static_cast<qreal>(glyphIndex) * GLYPH_SLOT +
|
||
(GLYPH_SLOT - side) / 2.0;
|
||
painter.drawPixmap(QPointF(x, rowTop + (GLYPH_SLOT - side) / 2.0), pixmap);
|
||
}
|
||
}
|
||
}
|
||
y += ROW_HEIGHT;
|
||
}
|
||
}
|
||
panelTop += panelHeight + 24;
|
||
}
|
||
|
||
painter.end();
|
||
const QString path = QDir(outDir).absoluteFilePath(
|
||
QStringLiteral("emphasis-treatments-%1-%2.png")
|
||
.arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||
ASSERT_TRUE(image.save(path)) << path.toStdString();
|
||
}
|
||
}
|
||
}
|