[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.
This commit is contained in:
Lukas Brübach 2026-11-08 20:36:21 +01:00
parent cc9c93eb57
commit 8847da74e2
10 changed files with 411 additions and 9 deletions

View file

@ -178,12 +178,13 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch
cfg.appColors[AppColor::IconStrong] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Primary); cfg.appColors[AppColor::IconStrong] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Primary);
cfg.appColors[AppColor::IconSoft] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Quiet); cfg.appColors[AppColor::IconSoft] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Quiet);
// Semantic roles stay independent of the accent: a generated palette must // Semantic roles stay independent of the accent: a generated palette must
// still paint add/ready green, remove/not-ready red, and the neutral // still paint add/ready green, remove/not-ready red, information blue,
// information state blue. // hidden information violet and marks amber.
for (const AppColor::Role role : for (const AppColor::Role role :
{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, AppColor::Negative, {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, AppColor::Negative,
AppColor::NegativeStrong, AppColor::NegativeSoft, AppColor::Informational, AppColor::InformationalStrong, AppColor::NegativeStrong, AppColor::NegativeSoft, AppColor::Informational, AppColor::InformationalStrong,
AppColor::InformationalSoft}) { AppColor::InformationalSoft, AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft, AppColor::Marked,
AppColor::MarkedStrong, AppColor::MarkedSoft}) {
cfg.appColors[role] = defaultAppColor(role, dark); cfg.appColors[role] = defaultAppColor(role, dark);
} }

View file

@ -83,6 +83,13 @@ static const QMap<AppColor::Role, const char *> APP_ROLE_DESCRIPTIONS = {
"Informational state that is neither a gain nor a loss (face-down, exile)")}, "Informational state that is neither a gain nor a loss (face-down, exile)")},
{AppColor::InformationalStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Informational state at primary emphasis")}, {AppColor::InformationalStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Informational state at primary emphasis")},
{AppColor::InformationalSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Informational state at quiet emphasis")}, {AppColor::InformationalSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Informational state at quiet emphasis")},
{AppColor::Hidden,
QT_TRANSLATE_NOOP("PaletteGridWidget", "Hidden information (face-down play, peek at the card face)")},
{AppColor::HiddenStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Hidden information at primary emphasis")},
{AppColor::HiddenSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Hidden information at quiet emphasis")},
{AppColor::Marked, QT_TRANSLATE_NOOP("PaletteGridWidget", "Marks left on a card (annotation, draw arrow)")},
{AppColor::MarkedStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Marks on a card at primary emphasis")},
{AppColor::MarkedSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Marks on a card at quiet emphasis")},
}; };
PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent)

View file

@ -491,6 +491,24 @@ QColor defaultAppColor(AppColor::Role role, bool dark)
return QColor(dark ? QStringLiteral("#69aeff") : QStringLiteral("#0a4fb5")); return QColor(dark ? QStringLiteral("#69aeff") : QStringLiteral("#0a4fb5"));
case AppColor::InformationalSoft: case AppColor::InformationalSoft:
return QColor(dark ? QStringLiteral("#9db2cd") : QStringLiteral("#526a8a")); return QColor(dark ? QStringLiteral("#9db2cd") : QStringLiteral("#526a8a"));
// Hidden is the game's hidden-information cluster: a face-down play and
// peeking at a card face. Violet keeps it apart from the blue
// Informational family, which carries public information (reveal).
case AppColor::Hidden:
return QColor(dark ? QStringLiteral("#c6a5fa") : QStringLiteral("#6d28d9"));
case AppColor::HiddenStrong:
return QColor(dark ? QStringLiteral("#c99bff") : QStringLiteral("#4c0f9e"));
case AppColor::HiddenSoft:
return QColor(dark ? QStringLiteral("#b3a6c9") : QStringLiteral("#6a5a8c"));
// Marked is the marks a player leaves on a card: an annotation or a
// draw arrow. Amber names a persistent, removable mark rather than a
// gain or a loss, so annotation and arrow finally share a colour.
case AppColor::Marked:
return QColor(dark ? QStringLiteral("#e2a63a") : QStringLiteral("#9a5b05"));
case AppColor::MarkedStrong:
return QColor(dark ? QStringLiteral("#ffb020") : QStringLiteral("#8a4f02"));
case AppColor::MarkedSoft:
return QColor(dark ? QStringLiteral("#c2ab7d") : QStringLiteral("#7b6a4a"));
} }
return {}; return {};

View file

@ -16,10 +16,10 @@
// //
// The icon families carry one role per emphasis level. Icon is the neutral // The icon families carry one role per emphasis level. Icon is the neutral
// glyph ink; IconStrong and IconSoft are its primary and quiet levels. Positive, // glyph ink; IconStrong and IconSoft are its primary and quiet levels. Positive,
// Negative and Informational follow the same pattern, so an emphasis never has // Negative, Informational, Hidden and Marked follow the same pattern, so an
// to be computed from a hue at paint time: the theme authors all three levels. // emphasis never has to be computed from a hue at paint time: the theme authors
// Naming a variant explicitly (`IconStrong`) pins that level; naming the family // all three levels. Naming a variant explicitly (`IconStrong`) pins that level;
// (`Icon`) lets the caller's emphasis choose. // naming the family (`Icon`) lets the caller's emphasis choose.
namespace AppColor namespace AppColor
{ {
Q_NAMESPACE Q_NAMESPACE
@ -39,6 +39,12 @@ enum Role
Informational, Informational,
InformationalStrong, InformationalStrong,
InformationalSoft, InformationalSoft,
Hidden,
HiddenStrong,
HiddenSoft,
Marked,
MarkedStrong,
MarkedSoft,
}; };
Q_ENUM_NS(Role) Q_ENUM_NS(Role)
} // namespace AppColor } // namespace AppColor

View file

@ -58,6 +58,14 @@ AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis)
return emphasis == IconEmphasis::Primary ? AppColor::InformationalStrong return emphasis == IconEmphasis::Primary ? AppColor::InformationalStrong
: emphasis == IconEmphasis::Quiet ? AppColor::InformationalSoft : emphasis == IconEmphasis::Quiet ? AppColor::InformationalSoft
: AppColor::Informational; : AppColor::Informational;
case AppColor::Hidden:
return emphasis == IconEmphasis::Primary ? AppColor::HiddenStrong
: emphasis == IconEmphasis::Quiet ? AppColor::HiddenSoft
: AppColor::Hidden;
case AppColor::Marked:
return emphasis == IconEmphasis::Primary ? AppColor::MarkedStrong
: emphasis == IconEmphasis::Quiet ? AppColor::MarkedSoft
: AppColor::Marked;
default: default:
return role; return role;
} }

View file

@ -686,6 +686,12 @@ QColor ThemeManager::appColor(AppColor::Role role) const
case AppColor::Informational: case AppColor::Informational:
case AppColor::InformationalStrong: case AppColor::InformationalStrong:
case AppColor::InformationalSoft: case AppColor::InformationalSoft:
case AppColor::Hidden:
case AppColor::HiddenStrong:
case AppColor::HiddenSoft:
case AppColor::Marked:
case AppColor::MarkedStrong:
case AppColor::MarkedSoft:
// Semantic colors are independent of the accent: a missing role must // Semantic colors are independent of the accent: a missing role must
// not silently paint "remove" in the theme's highlight color. // not silently paint "remove" in the theme's highlight color.
return defaultAppColor(role, isDarkModeActive()); return defaultAppColor(role, isDarkModeActive());

View file

@ -103,6 +103,8 @@ TEST(IconTintPreview, WritesOneSheetPerShippedTheme)
{"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}}, {"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}},
{"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}}, {"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}},
{"Informational", AppColor::Informational, {"arrow_right", "view", "arrow_to_top"}}, {"Informational", AppColor::Informational, {"arrow_right", "view", "arrow_to_top"}},
{"Hidden", AppColor::Hidden, {"view", "arrow_right", "swap"}},
{"Marked", AppColor::Marked, {"pencil", "share", "pin"}},
}; };
const QStringList themes = { const QStringList themes = {
QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"),

View file

@ -10,6 +10,7 @@
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFont> #include <QFont>
#include <QFontMetrics>
#include <QGuiApplication> #include <QGuiApplication>
#include <QImage> #include <QImage>
#include <QPainter> #include <QPainter>
@ -110,6 +111,8 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme)
{"Positive", AppColor::Positive, {"arrow_right", "increment", "arrow_left"}}, {"Positive", AppColor::Positive, {"arrow_right", "increment", "arrow_left"}},
{"Negative", AppColor::Negative, {"arrow_down", "decrement", "remove_row"}}, {"Negative", AppColor::Negative, {"arrow_down", "decrement", "remove_row"}},
{"Informational", AppColor::Informational, {"arrow_right", "view", "arrow_to_top"}}, {"Informational", AppColor::Informational, {"arrow_right", "view", "arrow_to_top"}},
{"Hidden", AppColor::Hidden, {"view", "arrow_right", "swap"}},
{"Marked", AppColor::Marked, {"pencil", "share", "pin"}},
}; };
const QStringList themes = { const QStringList themes = {
QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"),
@ -251,3 +254,312 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme)
} }
} }
} }
namespace
{
/** @brief One action-grid cell, with the role the panel hands the icon engine. */
struct PreviewAction
{
QString label;
const char *glyph;
AppColor::Role role;
};
/** @brief One titled section of a hover panel page. */
struct PreviewSection
{
QString title;
QList<PreviewAction> actions;
};
/** @brief One hover panel page, as the client builds it. */
struct PreviewPage
{
QString title;
QList<PreviewSection> sections;
};
/** @brief Renders a sentinel SVG in one ink at the requested pixel size. */
QPixmap renderInkGlyph(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;
}
} // namespace
/**
* @brief Writes the hover panel's action grids the way the client paints them.
*
* The treatment sheets show glyphs in isolation; this renders the real layout —
* section headers and two-column cells with the glyph above its label — so the
* emphasis split (quiet tabs, primary grid) and the semantic map can be judged
* as the set the player actually sees. The roles are the ones the panel passes
* to the icon engine, already resolved to the emphasis level it requests, and a
* role the theme does not author resolves through the same scheme default the
* client paints.
*/
TEST(IconTreatmentPreview, HoverPanelGridWritesPngsWhenRequested)
{
const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR");
if (outDir.isEmpty()) {
GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs";
}
ensureGuiApplication();
ASSERT_TRUE(QDir().mkpath(outDir));
constexpr int MARGIN = 24;
constexpr int COLUMNS = 2;
constexpr int CELL_WIDTH = 146;
constexpr int CELL_HEIGHT = 62;
constexpr int SECTION_HEIGHT = 22;
constexpr int PANEL_PADDING = 10;
constexpr int PANEL_TITLE = 24;
constexpr int GLYPH = 22;
constexpr qreal DPR = 2.0;
// The two pages a selected card shows, with every conditional action
// present so the whole map is visible at once.
const QList<PreviewPage> pages = {
PreviewPage{.title = QStringLiteral("Main — on the battlefield"),
.sections =
{
PreviewSection{.title = QStringLiteral("Battlefield"),
.actions =
{
PreviewAction{.label = QStringLiteral("Tap / Untap"),
.glyph = "sync",
.role = AppColor::IconStrong},
PreviewAction{.label = QStringLiteral("Skip untapping"),
.glyph = "lock",
.role = AppColor::IconStrong},
PreviewAction{.label = QStringLiteral("Turn Over"),
.glyph = "swap",
.role = AppColor::IconStrong},
PreviewAction{.label = QStringLiteral("Peek at card face"),
.glyph = "view",
.role = AppColor::HiddenStrong},
PreviewAction{.label = QStringLiteral("Clone"),
.glyph = "increment",
.role = AppColor::PositiveStrong},
PreviewAction{.label = QStringLiteral("Attach to card..."),
.glyph = "pin",
.role = AppColor::IconStrong},
PreviewAction{.label = QStringLiteral("Unattach"),
.glyph = "arrow_undo",
.role = AppColor::NegativeStrong},
PreviewAction{.label = QStringLiteral("Draw arrow..."),
.glyph = "share",
.role = AppColor::MarkedStrong},
PreviewAction{.label = QStringLiteral("Set annotation..."),
.glyph = "pencil",
.role = AppColor::MarkedStrong},
PreviewAction{.label = QStringLiteral("Reduce life"),
.glyph = "arrow_down",
.role = AppColor::NegativeStrong},
}},
PreviewSection{.title = QStringLiteral("Select"),
.actions =
{
PreviewAction{.label = QStringLiteral("Select All"),
.glyph = "search",
.role = AppColor::IconStrong},
PreviewAction{.label = QStringLiteral("Select Row"),
.glyph = "filter",
.role = AppColor::IconStrong},
}},
}},
PreviewPage{.title = QStringLiteral("Main — in a zone"),
.sections =
{
PreviewSection{.title = QStringLiteral("Zone"),
.actions =
{
PreviewAction{.label = QStringLiteral("Play"),
.glyph = "arrow_right",
.role = AppColor::PositiveStrong},
PreviewAction{.label = QStringLiteral("Play Face Down"),
.glyph = "arrow_right",
.role = AppColor::HiddenStrong},
PreviewAction{.label = QStringLiteral("Clone"),
.glyph = "increment",
.role = AppColor::PositiveStrong},
PreviewAction{.label = QStringLiteral("Attach to card..."),
.glyph = "pin",
.role = AppColor::IconStrong},
PreviewAction{.label = QStringLiteral("Draw arrow..."),
.glyph = "share",
.role = AppColor::MarkedStrong},
}},
PreviewSection{.title = QStringLiteral("Select"),
.actions =
{
PreviewAction{.label = QStringLiteral("Select All"),
.glyph = "search",
.role = AppColor::IconStrong},
PreviewAction{.label = QStringLiteral("Select Column"),
.glyph = "filter",
.role = AppColor::IconStrong},
}},
}},
PreviewPage{.title = QStringLiteral("Move — card in a zone"),
.sections =
{
PreviewSection{.title = QStringLiteral("Move to"),
.actions =
{
PreviewAction{.label = QStringLiteral("Top of library"),
.glyph = "arrow_to_top",
.role = AppColor::IconStrong},
PreviewAction{.label = QStringLiteral("X from top..."),
.glyph = "search",
.role = AppColor::IconStrong},
PreviewAction{.label = QStringLiteral("Bottom of library"),
.glyph = "arrow_to_bottom",
.role = AppColor::IconStrong},
PreviewAction{.label = QStringLiteral("Table"),
.glyph = "player",
.role = AppColor::IconStrong},
PreviewAction{.label = QStringLiteral("Hand"),
.glyph = "arrow_left",
.role = AppColor::PositiveStrong},
PreviewAction{.label = QStringLiteral("Graveyard"),
.glyph = "arrow_down",
.role = AppColor::NegativeStrong},
PreviewAction{.label = QStringLiteral("Exile"),
.glyph = "arrow_right",
.role = AppColor::InformationalStrong},
}},
PreviewSection{.title = QStringLiteral("Reveal to..."),
.actions =
{
PreviewAction{.label = QStringLiteral("All players"),
.glyph = "share",
.role = AppColor::InformationalStrong},
PreviewAction{.label = QStringLiteral("Alice"),
.glyph = "player",
.role = AppColor::InformationalStrong},
PreviewAction{.label = QStringLiteral("Bob"),
.glyph = "player",
.role = AppColor::InformationalStrong},
}},
}},
};
const auto rowsIn = [](const PreviewSection &section) { return (section.actions.size() + COLUMNS - 1) / COLUMNS; };
const auto panelHeightFor = [&](const PreviewPage &page) {
int height = PANEL_TITLE + PANEL_PADDING;
for (const PreviewSection &section : page.sections) {
height += SECTION_HEIGHT + rowsIn(section) * CELL_HEIGHT;
}
return height + PANEL_PADDING;
};
const int panelWidth = COLUMNS * CELL_WIDTH + 2 * PANEL_PADDING;
const int pageCount = static_cast<int>(pages.size());
int tallestPanel = 0;
for (const PreviewPage &page : pages) {
tallestPanel = qMax(tallestPanel, panelHeightFor(page));
}
const int logicalWidth = 2 * MARGIN + pageCount * panelWidth + (pageCount - 1) * MARGIN;
const int logicalHeight = MARGIN + 30 + tallestPanel + MARGIN;
const QString theme = QStringLiteral("Fabric");
for (const bool dark : {false, true}) {
const QString file =
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR))
.absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml")
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
const PaletteConfig config = PaletteConfig::fromFile(file);
ASSERT_TRUE(config.hasPalette()) << theme.toStdString();
const QPalette palette = config.apply(QPalette());
const QColor window = palette.color(QPalette::Active, QPalette::Window);
const QColor text = palette.color(QPalette::Active, QPalette::WindowText);
// The panel's cells sit on the popup surface; painting them on the
// button color is what a QToolButton shows when it is not hovered.
const QColor cell = palette.color(QPalette::Active, QPalette::Button);
QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)),
QImage::Format_ARGB32_Premultiplied);
image.fill(window);
QPainter painter(&image);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.scale(DPR, DPR);
painter.setPen(text);
painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold));
painter.drawText(QPointF(MARGIN, MARGIN),
QStringLiteral("%1 — %2 scheme, hover panel action grids")
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
const auto toneFor = [&](AppColor::Role role) {
return config.appColors.value(role, defaultAppColor(role, dark));
};
for (int pageIndex = 0; pageIndex < pageCount; ++pageIndex) {
const PreviewPage &page = pages[pageIndex];
const qreal panelLeft = MARGIN + static_cast<qreal>(pageIndex) * (panelWidth + MARGIN);
const qreal panelTop = MARGIN + 30;
const int panelHeight = panelHeightFor(page);
painter.setPen(Qt::NoPen);
painter.setBrush(cell.darker(dark ? 115 : 103));
painter.drawRoundedRect(QRectF(panelLeft, panelTop, panelWidth, panelHeight), 8, 8);
painter.setPen(text);
painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold));
painter.drawText(QPointF(panelLeft + PANEL_PADDING, panelTop + 17), page.title);
qreal y = panelTop + PANEL_TITLE;
for (const PreviewSection &section : page.sections) {
painter.setPen(text);
painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold));
painter.drawText(QPointF(panelLeft + PANEL_PADDING, y + 15), section.title);
y += SECTION_HEIGHT;
const int rows = rowsIn(section);
for (int row = 0; row < rows; ++row) {
for (int column = 0; column < COLUMNS; ++column) {
const int index = row * COLUMNS + column;
if (index >= section.actions.size()) {
break;
}
const PreviewAction &action = section.actions.at(index);
const QRectF cellRect(panelLeft + PANEL_PADDING + column * CELL_WIDTH, y, CELL_WIDTH - 4,
CELL_HEIGHT - 4);
painter.setPen(Qt::NoPen);
painter.setBrush(cell);
painter.drawRoundedRect(cellRect, 5, 5);
const QByteArray svg = readIconSvg(action.glyph);
ASSERT_FALSE(svg.isEmpty()) << action.glyph;
const QPixmap pixmap = renderAtDpr(QSize(GLYPH, GLYPH), DPR, [&](const QSize &pixelSize) {
return renderInkGlyph(svg, toneFor(action.role), pixelSize);
});
painter.drawPixmap(QPointF(cellRect.center().x() - GLYPH / 2.0, cellRect.top() + 7), pixmap);
painter.setPen(text);
painter.setFont(QFont(QStringLiteral("Sans"), 7));
const QFontMetrics metrics(painter.font());
const QString label = metrics.elidedText(action.label, Qt::ElideRight, CELL_WIDTH - 12);
painter.drawText(QRectF(cellRect.left(), cellRect.bottom() - 20, cellRect.width(), 16),
Qt::AlignHCenter | Qt::AlignVCenter, label);
}
y += CELL_HEIGHT;
}
}
}
painter.end();
const QString path = QDir(outDir).absoluteFilePath(
QStringLiteral("hover-panel-map-%1.png").arg(dark ? QStringLiteral("dark") : QStringLiteral("light")));
ASSERT_TRUE(image.save(path)) << path.toStdString();
}
}

View file

@ -1539,7 +1539,9 @@ TEST_F(ThemeIconRenderTest, EmphasisSelectsTheAuthoredVariant)
{Family{AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}, {Family{AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft},
Family{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, Family{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft},
Family{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}, Family{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft},
Family{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}}) { Family{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft},
Family{AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft},
Family{AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft}}) {
EXPECT_EQ(IconTint(family.base, IconEmphasis::Primary).resolve(QIcon::Normal), EXPECT_EQ(IconTint(family.base, IconEmphasis::Primary).resolve(QIcon::Normal),
themeManager->appColor(family.strong)); themeManager->appColor(family.strong));
EXPECT_EQ(IconTint(family.base).resolve(QIcon::Normal), themeManager->appColor(family.base)); EXPECT_EQ(IconTint(family.base).resolve(QIcon::Normal), themeManager->appColor(family.base));
@ -1651,6 +1653,28 @@ TEST_F(ThemeIconRenderTest, EmphasisKeepsASemanticHue)
EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast<int>(role); EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast<int>(role);
EXPECT_GT(ink.blue(), ink.green()) << 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);
}
} }
} }
EXPECT_GT(checked, 0) << "no shipped theme authored a semantic emphasis level"; EXPECT_GT(checked, 0) << "no shipped theme authored a semantic emphasis level";
@ -1837,7 +1861,9 @@ TEST(IconInk, DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces)
for (const auto &[base, strong, soft] : for (const auto &[base, strong, soft] :
{std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, {std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft},
std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}, std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft},
std::tuple{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}}) { std::tuple{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft},
std::tuple{AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft},
std::tuple{AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft}}) {
const QColor secondary = defaultAppColor(base, scheme.dark); const QColor secondary = defaultAppColor(base, scheme.dark);
const QColor primary = defaultAppColor(strong, scheme.dark); const QColor primary = defaultAppColor(strong, scheme.dark);
const QColor quiet = defaultAppColor(soft, scheme.dark); const QColor quiet = defaultAppColor(soft, scheme.dark);
@ -1929,6 +1955,8 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested)
AppColor::InformationalSoft, AppColor::InformationalSoft,
AppColor::Informational, AppColor::Informational,
AppColor::InformationalStrong}, AppColor::InformationalStrong},
{"Hidden", {"view", "arrow_right", "swap"}, AppColor::HiddenSoft, AppColor::Hidden, AppColor::HiddenStrong},
{"Marked", {"pencil", "share", "pin"}, AppColor::MarkedSoft, AppColor::Marked, AppColor::MarkedStrong},
}; };
const QStringList themes = { const QStringList themes = {
QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"),
@ -2127,6 +2155,8 @@ TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested)
AppColor::InformationalSoft, AppColor::InformationalSoft,
AppColor::Informational, AppColor::Informational,
AppColor::InformationalStrong}, AppColor::InformationalStrong},
{"Hidden", {"view", "arrow_right", "swap"}, AppColor::HiddenSoft, AppColor::Hidden, AppColor::HiddenStrong},
{"Marked", {"pencil", "share", "pin"}, AppColor::MarkedSoft, AppColor::Marked, AppColor::MarkedStrong},
}; };
const QStringList themes = { const QStringList themes = {
QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"),

View file

@ -173,6 +173,18 @@ TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme)
EXPECT_EQ(defaultAppColor(AppColor::InformationalStrong, true), QColor(QStringLiteral("#69aeff"))); EXPECT_EQ(defaultAppColor(AppColor::InformationalStrong, true), QColor(QStringLiteral("#69aeff")));
EXPECT_EQ(defaultAppColor(AppColor::InformationalSoft, false), QColor(QStringLiteral("#526a8a"))); EXPECT_EQ(defaultAppColor(AppColor::InformationalSoft, false), QColor(QStringLiteral("#526a8a")));
EXPECT_EQ(defaultAppColor(AppColor::InformationalSoft, true), QColor(QStringLiteral("#9db2cd"))); EXPECT_EQ(defaultAppColor(AppColor::InformationalSoft, true), QColor(QStringLiteral("#9db2cd")));
EXPECT_EQ(defaultAppColor(AppColor::Hidden, false), QColor(QStringLiteral("#6d28d9")));
EXPECT_EQ(defaultAppColor(AppColor::Hidden, true), QColor(QStringLiteral("#c6a5fa")));
EXPECT_EQ(defaultAppColor(AppColor::HiddenStrong, false), QColor(QStringLiteral("#4c0f9e")));
EXPECT_EQ(defaultAppColor(AppColor::HiddenStrong, true), QColor(QStringLiteral("#c99bff")));
EXPECT_EQ(defaultAppColor(AppColor::HiddenSoft, false), QColor(QStringLiteral("#6a5a8c")));
EXPECT_EQ(defaultAppColor(AppColor::HiddenSoft, true), QColor(QStringLiteral("#b3a6c9")));
EXPECT_EQ(defaultAppColor(AppColor::Marked, false), QColor(QStringLiteral("#9a5b05")));
EXPECT_EQ(defaultAppColor(AppColor::Marked, true), QColor(QStringLiteral("#e2a63a")));
EXPECT_EQ(defaultAppColor(AppColor::MarkedStrong, false), QColor(QStringLiteral("#8a4f02")));
EXPECT_EQ(defaultAppColor(AppColor::MarkedStrong, true), QColor(QStringLiteral("#ffb020")));
EXPECT_EQ(defaultAppColor(AppColor::MarkedSoft, false), QColor(QStringLiteral("#7b6a4a")));
EXPECT_EQ(defaultAppColor(AppColor::MarkedSoft, true), QColor(QStringLiteral("#c2ab7d")));
// Accent and neutral icon roles have no static default: an older theme // Accent and neutral icon roles have no static default: an older theme
// written before the roles existed falls back to deriving them from its // written before the roles existed falls back to deriving them from its
// palette rather than pinning one scheme's ink. // palette rather than pinning one scheme's ink.