mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-08 11:03:25 -08:00
[Icons] Tint semantic glyphs and add badge and sticker treatments (#7775)
* [Icons] Keep semantic red and green on themed glyphs The one-glyph cutover recolored every icon from a single palette role, so a disabled deck, a not-ready player and a remove button all lost the red or green that carried their meaning. Add Positive and Negative application colors beside the accents, ship scheme-appropriate defaults for every theme, and resolve them through ThemeManager. Icons now take an IconTint: a palette role for structural glyphs, or a semantic role for states and add/remove actions. A disabled semantic icon falls back to the disabled foreground; on a selected row a semantic tint keeps its color only while it stays legible on the highlight, and a palette tint keeps the existing WindowText -> HighlightedText swap. * [Icons] Render a semantic-tint sheet for every shipped theme The PR showed two sheets for one theme, which cannot show that the shipped reds and greens stay legible on every palette. Each shipped theme and scheme now gets its own sheet: both families, three glyphs, at 16 and 24 px, on the window and button surfaces the contrast is measured against, with the disabled tone beside each family. The generator lives in its own test file so the preview work does not have to move the branches above it. * [Icons] Add a badge icon treatment Restore the filled-disc look the ready/not-ready icons had before the one-glyph cutover, without bringing back palette-locked art. IconStyle::Badge composes the existing sentinel SVG at paint time: a gradient disc, a dark ring, and the glyph re-weighted and inked for contrast against the tone. Line stays the default and is unchanged. Only the two state icons opt in. The add/remove icons keep the line treatment because their old art was a filled glyph with an outline and no disc, so badging them would invent a look the UI never had. Disabled badges fall back to the line glyph so an unavailable control still reads as unavailable. * [Icons] Add a sticker icon treatment The pre-#7595 add, remove and delete icons were filled shapes with a black outline and no disc. A tint cannot bring that back, and the badge disc is a different icon language, so add a third value to the existing style axis. IconStyle::Sticker draws the sentinel glyph twice: a near-black rim at the band width plus one rim on each side, then the tone pass clipped to the glyph and filled with a vertical gradient. Both treatments keep the disabled fallback to the line glyph. The fill rule moves into theme_icon_fill: the semantic tones are tuned for text, so a filled shape gives its tone a saturation floor, and a dark-scheme tone is pulled down rather than up. IconTint::resolve() applies the same floor to line glyphs, and the badge disc uses the same fill, so a line, a badge and a sticker next to each other carry the same green or red instead of a pale line beside a saturated glyph. The badge's glyph ink follows the scheme: white on light-scheme tones, as in the pre-#7595 art, and the saturated tone darkened to an outline-dark ink on dark-scheme tones, where the shared saturated disc is light enough that white would sit near 2.7:1. The new test pins both cases so the glyph cannot silently flip. resolveSvgStrokeWidth() moves the stroke-width rewrite into libcockatrice_utility so the badge and the sticker share it; the badge's local copy is gone. The sticker is opted into at 17 call sites: increment, decrement and remove_row in the deck editor, settings pages, token dialog, server dialogs and replay/deck storage tabs. The visual database's clear-filter button keeps its trash can (icons/delete) in the line treatment: that glyph is new in #7595, but it is the clearer metaphor, and a sticker rim closes its lid gap at toolbar sizes. The preview generators also gain a semantic-tint sheet for #7775 and a line/badge/sticker family strip, so the three PR descriptions can show the same rendered colors. * [Icons] Render authored theme SVG art without the SVG image plugin `QPixmap("theme:file.svg")` needs Qt's SVG *image-format* plugin. Debian builds that as the separate `qt6-svg-plugins` package, which is only a Recommends of `qt6-svg-dev`, so a `--no-install-recommends` install (CI, minimal user installs) does not have it. Authored theme art then loaded as a null pixmap: the two authored-art render tests failed on Debian 13 and on the Ubuntu sanitize leg while passing on a developer machine whose Qt bundles the format. Render authored SVG assets through QSvgRenderer instead, which is already linked for the sentinel path and needs no plugin. Raster assets keep the QPixmap loader. The test suite now hides the plugin directories, so the dependency cannot come back unnoticed on platforms that ship the plugin. * [Icons] Render a badge/sticker sheet for every shipped theme The PR showed one theme's badge and sticker sheets, which cannot show that the filled treatments read on every palette. Each shipped theme and scheme now gets its own sheet: both semantic families, three glyphs, as line glyphs, stickers and badges, at 16 and 24 px, on the window and button surfaces the contrast is measured against, with the disabled fallback under each family. The generator lives in its own test file so the preview work does not have to move the branches above it. * [Icons] Show the trash can with the sticker treatment again The sticker sheet kept the delete glyph out of the sticker rows and explained why: at toolbar sizes the rim closed the lid gap. The icon is used with the sticker treatment again for destructive actions, so the sheet now shows it and the exception note is gone. * [Icons] Apply the CI clang-format output to the treatment preview clang-format 18 wraps the family label and the glyph x offset differently from the local clang-format 23. The preview test takes the CI output so the bottom of the stack passes the style job on its own. * [Icons] Rewrite the icon role docs in plain language Address review: drop the AI-cadence prose from the IconStyle/IconTint and treatment headers, use ///< for the enum member docs, and move theme_icon.cpp's anonymous namespace above the IconTint definitions. No behavior change. * Address review findings --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
parent
1ed7e45357
commit
e81ee02c24
49 changed files with 2351 additions and 72 deletions
|
|
@ -152,6 +152,9 @@ set(cockatrice_SOURCES
|
|||
src/interface/pixel_map_generator.cpp
|
||||
src/interface/theme_config.cpp
|
||||
src/interface/theme_icon.cpp
|
||||
src/interface/theme_icon_badge.cpp
|
||||
src/interface/theme_icon_fill.cpp
|
||||
src/interface/theme_icon_sticker.cpp
|
||||
src/interface/theme_manager.cpp
|
||||
src/interface/palette_editor/color_button.cpp
|
||||
src/interface/palette_editor/palette_generator.cpp
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent)
|
|||
typeCombo->addItem(CardFilter::typeName(static_cast<CardFilter::Type>(i)), QVariant(i));
|
||||
}
|
||||
|
||||
QPushButton *ok = new QPushButton(loadThemedIcon(QStringLiteral("icons/increment")), QString());
|
||||
QPushButton *ok = new QPushButton(
|
||||
loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker), QString());
|
||||
ok->setObjectName("ok");
|
||||
ok->setMaximumSize(20, 20);
|
||||
|
||||
|
|
|
|||
|
|
@ -68,8 +68,8 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor,
|
|||
QWidget *parent)
|
||||
: QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false)
|
||||
{
|
||||
readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start"));
|
||||
notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start"));
|
||||
readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge);
|
||||
notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start"), AppColor::Negative, IconStyle::Badge);
|
||||
concededIcon = loadThemedIcon(QStringLiteral("icons/conceded"));
|
||||
playerIcon = loadThemedIcon(QStringLiteral("icons/player"));
|
||||
judgeIcon = loadThemedIcon(QStringLiteral("icons/scales"));
|
||||
|
|
|
|||
|
|
@ -167,6 +167,10 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch
|
|||
// the lightened, desaturated companion used for button-gradient highlights.
|
||||
cfg.appColors[AppColor::AccentStrong] = hl;
|
||||
cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0));
|
||||
// Semantic roles stay independent of the accent: a generated palette must
|
||||
// still paint add/ready green and remove/not-ready red.
|
||||
cfg.appColors[AppColor::Positive] = defaultAppColor(AppColor::Positive, dark);
|
||||
cfg.appColors[AppColor::Negative] = defaultAppColor(AppColor::Negative, dark);
|
||||
|
||||
// BrightText
|
||||
QColor bright;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
#include <QApplication>
|
||||
#include <QColor>
|
||||
#include <QCoreApplication>
|
||||
#include <QEvent>
|
||||
#include <QFont>
|
||||
#include <QFrame>
|
||||
|
|
@ -60,8 +61,14 @@ static const QMap<QPalette::ColorRole, const char *> ROLE_DESCRIPTIONS = {
|
|||
};
|
||||
|
||||
static const QMap<AppColor::Role, const char *> APP_ROLE_DESCRIPTIONS = {
|
||||
{AppColor::AccentStrong, QT_TR_NOOP("Vivid primary accent (e.g. home-tab button gradient start)")},
|
||||
{AppColor::AccentSoft, QT_TR_NOOP("Lightened, desaturated accent (e.g. home-tab button gradient end)")},
|
||||
{AppColor::AccentStrong,
|
||||
QT_TRANSLATE_NOOP("PaletteGridWidget", "Vivid primary accent (e.g. home-tab button gradient start)")},
|
||||
{AppColor::AccentSoft,
|
||||
QT_TRANSLATE_NOOP("PaletteGridWidget", "Lightened, desaturated accent (e.g. home-tab button gradient end)")},
|
||||
{AppColor::Positive,
|
||||
QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state and additive actions (ready, add, increment)")},
|
||||
{AppColor::Negative,
|
||||
QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state and removal actions (not ready, remove, decrement)")},
|
||||
};
|
||||
|
||||
PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent)
|
||||
|
|
@ -179,7 +186,7 @@ void PaletteGridWidget::buildGrid(QWidget *host)
|
|||
}
|
||||
|
||||
auto *label = new QLabel(QString(appEnum.valueToKey(role)), host);
|
||||
label->setToolTip(APP_ROLE_DESCRIPTIONS.value(role, {}));
|
||||
label->setToolTip(QCoreApplication::translate("PaletteGridWidget", APP_ROLE_DESCRIPTIONS.value(role, {})));
|
||||
label->setContentsMargins(4, 2, 8, 2);
|
||||
grid->addWidget(label, row, 0);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
#include <QList>
|
||||
#include <QMetaEnum>
|
||||
#include <QPair>
|
||||
#include <QStringLiteral>
|
||||
#include <QTextStream>
|
||||
#include <qnamespace.h>
|
||||
#include <utility>
|
||||
|
|
@ -451,3 +452,19 @@ FontConfig FontConfig::fromThemeDir(const QString &themeDirPath)
|
|||
|
||||
return fromFile(QDir(themeDirPath).absoluteFilePath(fileName()));
|
||||
}
|
||||
|
||||
QColor defaultAppColor(AppColor::Role role, bool dark)
|
||||
{
|
||||
switch (role) {
|
||||
case AppColor::AccentStrong:
|
||||
case AppColor::AccentSoft:
|
||||
// Derived from the palette highlight by ThemeManager::appColor().
|
||||
return {};
|
||||
case AppColor::Positive:
|
||||
return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1b7f3b"));
|
||||
case AppColor::Negative:
|
||||
return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e"));
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ enum Role
|
|||
{
|
||||
AccentStrong,
|
||||
AccentSoft,
|
||||
Positive,
|
||||
Negative,
|
||||
};
|
||||
Q_ENUM_NS(Role)
|
||||
} // namespace AppColor
|
||||
|
|
@ -41,6 +43,16 @@ enum Role
|
|||
Q_ENUM_NS(Role)
|
||||
} // namespace AppFont
|
||||
|
||||
/**
|
||||
* @brief Fallback for an application color role when the active theme does not
|
||||
* define one.
|
||||
*
|
||||
* Accent roles return an invalid QColor because ThemeManager derives them from
|
||||
* the palette highlight. Semantic roles return an opaque default that suits the
|
||||
* scheme, so themes written before the role existed still render it.
|
||||
*/
|
||||
QColor defaultAppColor(AppColor::Role role, bool dark);
|
||||
|
||||
struct ThemeConfig
|
||||
{
|
||||
QString colorScheme;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
#include "theme_icon.h"
|
||||
|
||||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
#include "theme_icon_badge.h"
|
||||
#include "theme_icon_fill.h"
|
||||
#include "theme_icon_sticker.h"
|
||||
#include "theme_manager.h"
|
||||
|
||||
#include <QApplication>
|
||||
|
|
@ -27,17 +30,6 @@
|
|||
|
||||
namespace
|
||||
{
|
||||
/**
|
||||
* @brief Palette color that replaces `currentColor`, following the icon mode.
|
||||
*/
|
||||
QByteArray sentinelColor(QIcon::Mode mode, QPalette::ColorRole role)
|
||||
{
|
||||
const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active;
|
||||
const QPalette::ColorRole effective =
|
||||
mode == QIcon::Selected && role == QPalette::WindowText ? QPalette::HighlightedText : role;
|
||||
return qApp->palette().color(group, effective).name(QColor::HexRgb).toLatin1();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Reads an SVG through the `theme:` search path.
|
||||
*/
|
||||
|
|
@ -99,14 +91,19 @@ QString cacheKey(const QString &path, const QByteArray &variant, const QSize &si
|
|||
* and the intrinsic size are cached until the theme changes: a repaint then
|
||||
* does no file I/O at all. The render cache is keyed by resolved path and pixel
|
||||
* size plus a variant: the palette color for sentinel SVGs, the icon mode for
|
||||
* assets that carry their own colors. A palette change therefore produces new
|
||||
* keys instead of reusing art built under the previous palette, and the
|
||||
* non-sentinel path (raster overrides, plain SVGs) is cached like any other.
|
||||
* assets that carry their own colors, and the tone plus glyph color for a badge.
|
||||
* A palette change therefore produces new keys instead of reusing art built
|
||||
* under the previous palette, and the non-sentinel path (raster overrides, plain
|
||||
* SVGs) is cached like any other.
|
||||
*
|
||||
* The engine only resolves and caches. The badge shape itself is painted by
|
||||
* ThemeIconBadge so the compositing stays out of the resolution path.
|
||||
*/
|
||||
class ThemeSvgIconEngine final : public QObject, public QIconEngine
|
||||
{
|
||||
public:
|
||||
ThemeSvgIconEngine(QString _prefix, QPalette::ColorRole _role) : prefix(std::move(_prefix)), role(_role)
|
||||
ThemeSvgIconEngine(QString _prefix, IconTint _tint, IconStyle _style)
|
||||
: prefix(std::move(_prefix)), tint(_tint), style(_style)
|
||||
{
|
||||
if (themeManager) {
|
||||
// A theme switch changes the resolved asset (scheme-qualified
|
||||
|
|
@ -120,7 +117,7 @@ public:
|
|||
|
||||
QIconEngine *clone() const override
|
||||
{
|
||||
return new ThemeSvgIconEngine(prefix, role);
|
||||
return new ThemeSvgIconEngine(prefix, tint, style);
|
||||
}
|
||||
|
||||
void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State) override
|
||||
|
|
@ -220,9 +217,21 @@ private:
|
|||
|
||||
QPixmap fromAsset(const QSize &pixelSize, QIcon::Mode mode) const
|
||||
{
|
||||
QPixmap pixmap(QStringLiteral("theme:") + resolvedPath);
|
||||
if (!pixelSize.isEmpty() && pixmap.size() != pixelSize) {
|
||||
pixmap = pixmap.scaled(pixelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
||||
QPixmap pixmap;
|
||||
if (resolvedAssetIsSvg) {
|
||||
// Render authored SVG art through QSvgRenderer rather than QPixmap.
|
||||
// Loading an `.svg` through QPixmap needs Qt's SVG *image-format*
|
||||
// plugin, which is a separate package on Linux distributions and is
|
||||
// absent from minimal installs and from CI (it is a Recommends of
|
||||
// qt6-svg-dev, so `--no-install-recommends` drops it). Art a theme
|
||||
// ships must not disappear because of a packaging choice, and the
|
||||
// renderer is already linked for the sentinel path.
|
||||
pixmap = renderSvg(resolvedSvg, pixelSize.isEmpty() ? intrinsicSize() : pixelSize, 1.0);
|
||||
} else {
|
||||
pixmap = QPixmap(QStringLiteral("theme:") + resolvedPath);
|
||||
if (!pixelSize.isEmpty() && pixmap.size() != pixelSize) {
|
||||
pixmap = pixmap.scaled(pixelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
||||
}
|
||||
}
|
||||
if (mode == QIcon::Disabled && !pixmap.isNull()) {
|
||||
pixmap = QApplication::style()->generatedIconPixmap(QIcon::Disabled, pixmap, nullptr);
|
||||
|
|
@ -234,11 +243,35 @@ private:
|
|||
{
|
||||
ensureResolved();
|
||||
|
||||
// Same cache for both paths: sentinel art varies by palette color, art
|
||||
// Badge and Sticker are shape treatments, so they only apply to sentinel
|
||||
// art: an authored asset keeps its own drawing and follows the line/asset
|
||||
// path. Disabled also drops back to the line glyph: a filled disc or
|
||||
// sticker at full mass does not read as unavailable next to the thin line
|
||||
// glyphs around it, and the tint already removes the semantic tone.
|
||||
const bool treated = style != IconStyle::Line && resolvedHasSentinel && mode != QIcon::Disabled;
|
||||
const bool badge = treated && style == IconStyle::Badge;
|
||||
const bool sticker = treated && style == IconStyle::Sticker;
|
||||
// Same cache for all paths: sentinel art varies by palette color, art
|
||||
// with its own colors varies by mode (Disabled is generated). A color
|
||||
// variant is never a bare digit, so the two key spaces cannot collide.
|
||||
const QByteArray variant =
|
||||
resolvedHasSentinel ? sentinelColor(mode, role) : QByteArray::number(static_cast<int>(mode));
|
||||
// variant is never a bare digit, so the key spaces cannot collide. A
|
||||
// badge also varies by glyph color, because the same tone can switch
|
||||
// between a white and a near-black glyph at different lightnesses.
|
||||
// The selected-row contrast fallback exists for a thin glyph painted
|
||||
// directly on the highlight. A badge or sticker brings its own ring and
|
||||
// contrast glyph, so repainting its whole tone with HighlightedText
|
||||
// would collapse every badge on a selected row into one gray disc.
|
||||
const QColor tone = resolvedHasSentinel ? tint.resolve(treated ? QIcon::Normal : mode) : QColor();
|
||||
QByteArray variant;
|
||||
if (badge) {
|
||||
variant = QByteArrayLiteral("B|") + tone.name(QColor::HexRgb).toLatin1() + '|' +
|
||||
ThemeIconBadge::glyphColor(tone).name(QColor::HexRgb).toLatin1();
|
||||
} else if (sticker) {
|
||||
variant = QByteArrayLiteral("S|") + tone.name(QColor::HexRgb).toLatin1();
|
||||
} else if (resolvedHasSentinel) {
|
||||
variant = tone.name(QColor::HexRgb).toLatin1();
|
||||
} else {
|
||||
variant = QByteArray::number(static_cast<int>(mode));
|
||||
}
|
||||
const QString key = cacheKey(resolvedPath, variant, pixelSize, devicePixelRatio);
|
||||
const auto cached = cache.constFind(key);
|
||||
if (cached != cache.constEnd()) {
|
||||
|
|
@ -246,7 +279,13 @@ private:
|
|||
}
|
||||
|
||||
QPixmap pixmap;
|
||||
if (resolvedHasSentinel) {
|
||||
if (badge) {
|
||||
// Geometry is computed from the device-pixel size, and the ratio is
|
||||
// attached below like every other path.
|
||||
pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize);
|
||||
} else if (sticker) {
|
||||
pixmap = ThemeIconSticker::renderSticker(resolvedSvg, tone, pixelSize);
|
||||
} else if (resolvedHasSentinel) {
|
||||
pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, variant), pixelSize, devicePixelRatio);
|
||||
} else {
|
||||
// Authored art carries its own colors: use Qt's normal loader so
|
||||
|
|
@ -266,7 +305,8 @@ private:
|
|||
}
|
||||
|
||||
QString prefix;
|
||||
QPalette::ColorRole role;
|
||||
IconTint tint;
|
||||
IconStyle style;
|
||||
mutable QHash<QString, QPixmap> cache;
|
||||
mutable QString resolvedPath;
|
||||
mutable QString resolvedThemePath;
|
||||
|
|
@ -279,12 +319,51 @@ private:
|
|||
};
|
||||
} // namespace
|
||||
|
||||
QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role)
|
||||
IconTint::IconTint(QPalette::ColorRole role) : paletteRole(role)
|
||||
{
|
||||
return QIcon(new ThemeSvgIconEngine(prefix.toString(), role));
|
||||
}
|
||||
|
||||
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole role, const QSize &size)
|
||||
IconTint::IconTint(AppColor::Role role) : source(Source::AppColor), appRole(role)
|
||||
{
|
||||
}
|
||||
|
||||
QColor IconTint::resolve(QIcon::Mode mode) const
|
||||
{
|
||||
if (source == Source::AppColor) {
|
||||
// A disabled control must not keep signalling its semantic color.
|
||||
if (mode == QIcon::Disabled) {
|
||||
return qApp->palette().color(QPalette::Disabled, QPalette::WindowText);
|
||||
}
|
||||
// The three treatments paint the same tone: a line glyph, a badge disc
|
||||
// and a sticker fill must not disagree on how saturated the role is.
|
||||
// The fill treatments apply this floor themselves, so this only changes
|
||||
// the line path, and only for the desaturated dark-scheme tones.
|
||||
const QColor color = ThemeIconFill::saturated(themeManager->appColor(appRole));
|
||||
if (mode != QIcon::Selected) {
|
||||
return color;
|
||||
}
|
||||
// Selected rows paint their icon on the highlight colour. Keep the
|
||||
// semantic tint when it stays legible there; otherwise the glyph would
|
||||
// vanish into the selection.
|
||||
constexpr double MIN_SELECTED_CONTRAST = 3.0;
|
||||
const QColor highlight = qApp->palette().color(QPalette::Active, QPalette::Highlight);
|
||||
return ThemeIconBadge::contrastRatio(color, highlight) >= MIN_SELECTED_CONTRAST
|
||||
? color
|
||||
: qApp->palette().color(QPalette::Active, QPalette::HighlightedText);
|
||||
}
|
||||
|
||||
const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active;
|
||||
const QPalette::ColorRole effective =
|
||||
mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole;
|
||||
return qApp->palette().color(group, effective);
|
||||
}
|
||||
|
||||
QIcon loadThemedIcon(QStringView prefix, IconTint tint, IconStyle style)
|
||||
{
|
||||
return QIcon(new ThemeSvgIconEngine(prefix.toString(), tint, style));
|
||||
}
|
||||
|
||||
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const QSize &size, IconStyle style)
|
||||
{
|
||||
if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) {
|
||||
return {};
|
||||
|
|
@ -295,6 +374,15 @@ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole r
|
|||
return {};
|
||||
}
|
||||
|
||||
const QByteArray color = qApp->palette().color(role).name(QColor::HexRgb).toLatin1();
|
||||
return renderSvg(resolveSvgColorSentinel(svg, color), size, 1.0);
|
||||
const QColor tone = tint.resolve(QIcon::Normal);
|
||||
if (style == IconStyle::Badge || style == IconStyle::Sticker) {
|
||||
QSize canvas = size;
|
||||
if (canvas.isEmpty()) {
|
||||
canvas = QSvgRenderer(svg).defaultSize();
|
||||
}
|
||||
return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas)
|
||||
: ThemeIconSticker::renderSticker(svg, tone, canvas);
|
||||
}
|
||||
|
||||
return renderSvg(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()), size, 1.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
#ifndef THEME_ICON_H
|
||||
#define THEME_ICON_H
|
||||
|
||||
#include "theme_config.h"
|
||||
|
||||
#include <QColor>
|
||||
#include <QIcon>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
|
|
@ -13,6 +16,73 @@
|
|||
#include <QString>
|
||||
#include <QStringView>
|
||||
|
||||
/**
|
||||
* @brief Selects how a `currentColor` glyph is painted.
|
||||
*
|
||||
* The tint decides the tone. The style decides how much shape carries it.
|
||||
*
|
||||
* Line is the default and matches what every caller did before this axis
|
||||
* existed: the recolored glyph alone, at the author's stroke weight. Use it for
|
||||
* navigation, menu and toolbar glyphs, where a row of badges would compete with
|
||||
* the content.
|
||||
*
|
||||
* Badge reproduces the pre-#7595 ready/not-ready art: a filled disc, a dark ring
|
||||
* and a contrast glyph derived from the tone. A filled shape draws more
|
||||
* attention than a line, so it is opt-in per call site. It was made for the
|
||||
* ready/not-ready icons. The add/remove/delete icons were filled glyphs with an
|
||||
* outline and no disc, so badging them would produce art the UI never had.
|
||||
* Authored art that carries its own colors is never badged and falls back to the
|
||||
* asset path. Disabled icons fall back to the line glyph too, because a filled
|
||||
* disc at full mass does not read as unavailable.
|
||||
*
|
||||
* Sticker reproduces the other half of the pre-#7595 set: the filled add, remove
|
||||
* and delete glyphs, painted with a tone gradient and a dark outline and no
|
||||
* disc. It shares the disabled fallback for the same reason.
|
||||
*/
|
||||
enum class IconStyle
|
||||
{
|
||||
Line, ///< the recolored glyph alone
|
||||
Badge, ///< filled disc + dark ring + contrast glyph
|
||||
Sticker, ///< filled glyph + dark outline, no disc
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Selects the color that replaces the `currentColor` sentinel.
|
||||
*
|
||||
* A palette role follows Qt's structural palette. An application color carries
|
||||
* meaning that QPalette cannot express, such as positive and negative states.
|
||||
* Callers choose the role. The active theme supplies the value.
|
||||
*/
|
||||
class IconTint
|
||||
{
|
||||
public:
|
||||
IconTint() = default;
|
||||
IconTint(QPalette::ColorRole role); // implicit: existing palette-role callers
|
||||
IconTint(AppColor::Role role); // implicit: semantic callers
|
||||
|
||||
/**
|
||||
* @brief Resolves the concrete color for an icon mode.
|
||||
*
|
||||
* A disabled application color resolves to the disabled foreground, so an
|
||||
* unavailable control does not keep its semantic color. A selected palette
|
||||
* color keeps the WindowText -> HighlightedText swap. Application colors go
|
||||
* through the saturation floor shared by the filled treatments, so a line
|
||||
* glyph, a badge disc and a sticker fill carry the same tone.
|
||||
*/
|
||||
QColor resolve(QIcon::Mode mode) const;
|
||||
|
||||
private:
|
||||
enum class Source
|
||||
{
|
||||
Palette,
|
||||
AppColor,
|
||||
};
|
||||
|
||||
Source source = Source::Palette;
|
||||
QPalette::ColorRole paletteRole = QPalette::WindowText;
|
||||
AppColor::Role appRole = AppColor::AccentStrong;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Builds a palette-aware icon from a `theme:` asset.
|
||||
*
|
||||
|
|
@ -24,11 +94,12 @@
|
|||
*
|
||||
* @param prefix Asset prefix relative to the `theme:` search path, with no file
|
||||
* extension (for example `icons/search`), matching themePixmap().
|
||||
* @param role Palette role whose color replaces the sentinel. Selected glyphs
|
||||
* use HighlightedText unless a role other than WindowText is given.
|
||||
* @param tint Color that replaces the sentinel: a palette role or a semantic
|
||||
* application color. Defaults to WindowText.
|
||||
* @param style Treatment of the glyph: Line, Badge or Sticker. Defaults to Line.
|
||||
* @return An icon that follows the application palette.
|
||||
*/
|
||||
QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::WindowText);
|
||||
QIcon loadThemedIcon(QStringView prefix, IconTint tint = {}, IconStyle style = IconStyle::Line);
|
||||
|
||||
/**
|
||||
* @brief Renders a resolved theme SVG when it carries the `currentColor` sentinel.
|
||||
|
|
@ -38,13 +109,17 @@ QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::Wi
|
|||
*
|
||||
* @param resolvedPath Asset path relative to the `theme:` search path, as
|
||||
* returned by ThemeManager::assetPath (includes extension).
|
||||
* @param role Palette role whose color replaces the sentinel.
|
||||
* @param tint Color that replaces the sentinel: a palette role or a semantic
|
||||
* application color. Defaults to WindowText.
|
||||
* @param size Pixel size to render at; an empty size uses the SVG's intrinsic size.
|
||||
* @param style Treatment of the glyph: Line, Badge or Sticker. A treatment on a
|
||||
* non-sentinel asset stays null, like the line path.
|
||||
* @return The recolored pixmap, or a null pixmap when the asset is not a
|
||||
* sentinel-carrying SVG.
|
||||
*/
|
||||
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath,
|
||||
QPalette::ColorRole role = QPalette::WindowText,
|
||||
const QSize &size = {});
|
||||
IconTint tint = {},
|
||||
const QSize &size = {},
|
||||
IconStyle style = IconStyle::Line);
|
||||
|
||||
#endif // THEME_ICON_H
|
||||
|
|
|
|||
137
cockatrice/src/interface/theme_icon_badge.cpp
Normal file
137
cockatrice/src/interface/theme_icon_badge.cpp
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
#include "theme_icon_badge.h"
|
||||
|
||||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
#include "theme_icon_fill.h"
|
||||
|
||||
#include <QLinearGradient>
|
||||
#include <QPainter>
|
||||
#include <QPen>
|
||||
#include <QPointF>
|
||||
#include <QRectF>
|
||||
#include <QSvgRenderer>
|
||||
#include <QtGlobal>
|
||||
#include <cmath>
|
||||
|
||||
namespace
|
||||
{
|
||||
// The pre-#7595 badge art filled almost the whole canvas: a 24px icon carried a
|
||||
// 23px disc with a hairline black outline. Keep the disc large and the ring
|
||||
// thin so the glyph still has room at 16px.
|
||||
constexpr double RING_WIDTH_RATIO = 0.04;
|
||||
// The sentinel paths sit inside a padded 24-unit viewBox (the check spans 4..20),
|
||||
// so the glyph box has to be nearly the whole disc for the engraved mark to
|
||||
// match the old art's weight. At half the disc it read as a smudge at 15px.
|
||||
constexpr double GLYPH_DIAMETER_RATIO = 0.9;
|
||||
// The sentinel set is drawn at 2 units in a 24-unit viewBox, which turns into a
|
||||
// hairline once the glyph is scaled into a 15px disc. The old badge carried a
|
||||
// band near a tenth of the disc diameter, so the badge decides the glyph weight
|
||||
// instead of inheriting the authored one.
|
||||
constexpr qreal GLYPH_STROKE_WIDTH = 3.0;
|
||||
constexpr int RING_DARKER = 300;
|
||||
constexpr int GLYPH_DARKER = 400;
|
||||
} // namespace
|
||||
|
||||
namespace
|
||||
{
|
||||
struct DiscFill
|
||||
{
|
||||
QColor top;
|
||||
QColor bottom;
|
||||
};
|
||||
|
||||
DiscFill discFill(const QColor &tone)
|
||||
{
|
||||
return {ThemeIconFill::topColor(tone), ThemeIconFill::bottomColor(tone)};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace ThemeIconBadge
|
||||
{
|
||||
double relativeLuminance(const QColor &color)
|
||||
{
|
||||
const auto linear = [](double channel) {
|
||||
channel /= 255.0;
|
||||
return channel <= 0.03928 ? channel / 12.92 : std::pow((channel + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * linear(color.red()) + 0.7152 * linear(color.green()) + 0.0722 * linear(color.blue());
|
||||
}
|
||||
|
||||
double contrastRatio(const QColor &first, const QColor &second)
|
||||
{
|
||||
const double a = relativeLuminance(first);
|
||||
const double b = relativeLuminance(second);
|
||||
return (qMax(a, b) + 0.05) / (qMin(a, b) + 0.05);
|
||||
}
|
||||
|
||||
QColor glyphColor(const QColor &tone)
|
||||
{
|
||||
// The badge carries the ink the pre-sticker badge did: white on the light
|
||||
// scheme, and the tone darkened to an outline-dark ink on the dark scheme.
|
||||
// The shared saturated disc is light enough there that white sits near
|
||||
// 2.7:1 at the top, so the dark ink is the deliberate choice rather than a
|
||||
// contrast measurement that a palette tweak could flip. Deriving it from
|
||||
// the tone keeps themes in control.
|
||||
return ThemeIconFill::isDarkSchemeTone(tone) ? ThemeIconFill::saturated(tone).darker(GLYPH_DARKER)
|
||||
: QColor(Qt::white);
|
||||
}
|
||||
|
||||
QSize glyphSize(const QSize &pixelSize)
|
||||
{
|
||||
if (pixelSize.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
const qreal side = qMin(pixelSize.width(), pixelSize.height());
|
||||
const qreal ringWidth = qMax(1.0, side * RING_WIDTH_RATIO);
|
||||
const qreal discDiameter = side - 2.0 * ringWidth;
|
||||
const int glyphSide = qMax(1, qRound(discDiameter * GLYPH_DIAMETER_RATIO));
|
||||
return QSize(glyphSide, glyphSide);
|
||||
}
|
||||
|
||||
QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize)
|
||||
{
|
||||
if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QPixmap pixmap(pixelSize);
|
||||
pixmap.fill(Qt::transparent);
|
||||
|
||||
const qreal side = qMin(pixelSize.width(), pixelSize.height());
|
||||
const qreal ringWidth = qMax(1.0, side * RING_WIDTH_RATIO);
|
||||
const qreal discDiameter = side - 2.0 * ringWidth;
|
||||
|
||||
QRectF discRect(0, 0, discDiameter, discDiameter);
|
||||
discRect.moveCenter(QRectF(QPointF(0, 0), QSizeF(pixelSize)).center());
|
||||
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
|
||||
QLinearGradient gradient(discRect.topLeft(), discRect.bottomLeft());
|
||||
const DiscFill fill = discFill(tone);
|
||||
gradient.setColorAt(0.0, fill.top);
|
||||
gradient.setColorAt(1.0, fill.bottom);
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(gradient);
|
||||
painter.drawEllipse(discRect);
|
||||
|
||||
// The ring closes the disc: its outer edge touches the canvas, its inner
|
||||
// edge is the disc boundary, so the two meet without a seam.
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.setPen(QPen(ThemeIconFill::saturated(tone).darker(RING_DARKER), ringWidth));
|
||||
const qreal ringRadius = (side - ringWidth) / 2.0;
|
||||
painter.drawEllipse(discRect.center(), ringRadius, ringRadius);
|
||||
|
||||
const QByteArray glyphSvg = resolveSvgStrokeWidth(
|
||||
resolveSvgColorSentinel(svg, glyphColor(tone).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH);
|
||||
QSvgRenderer renderer(glyphSvg);
|
||||
if (renderer.isValid()) {
|
||||
const QSize size = glyphSize(pixelSize);
|
||||
const QRectF glyphRect(
|
||||
QPointF(discRect.center().x() - size.width() / 2.0, discRect.center().y() - size.height() / 2.0),
|
||||
QSizeF(size));
|
||||
renderer.render(&painter, glyphRect);
|
||||
}
|
||||
|
||||
return pixmap;
|
||||
}
|
||||
} // namespace ThemeIconBadge
|
||||
81
cockatrice/src/interface/theme_icon_badge.h
Normal file
81
cockatrice/src/interface/theme_icon_badge.h
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* @file theme_icon_badge.h
|
||||
* @ingroup Interface
|
||||
*/
|
||||
|
||||
#ifndef THEME_ICON_BADGE_H
|
||||
#define THEME_ICON_BADGE_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QColor>
|
||||
#include <QPixmap>
|
||||
#include <QSize>
|
||||
|
||||
/**
|
||||
* @brief Paints the badge treatment of a `currentColor` glyph.
|
||||
*
|
||||
* A tint alone changes the color of a 2px line, but the UI drew ready and
|
||||
* not-ready states as a filled badge before the one-glyph cutover. The badge
|
||||
* treatment keeps that shape: a filled disc, a dark ring and a contrast glyph,
|
||||
* all derived from the tone the tint resolves to. The theme still owns the
|
||||
* color. Callers only choose between a line and a badge.
|
||||
*
|
||||
* A badge survives a busy toolbar and a tinted row better than a hairline, and
|
||||
* its glyph can be light or dark regardless of the surrounding text. The cost is
|
||||
* more attention per control, and the glyph color follows the scheme it is drawn
|
||||
* for instead of the window's text color.
|
||||
*
|
||||
* Everything here works in device pixels. The icon engine renders at the
|
||||
* device-pixel size it already computed and sets the pixmap's device pixel ratio
|
||||
* afterwards, so badge geometry must not assume logical pixels.
|
||||
*/
|
||||
namespace ThemeIconBadge
|
||||
{
|
||||
/**
|
||||
* @brief WCAG relative luminance, used to test a color against a background.
|
||||
*
|
||||
* Used by IconTint::resolve()'s selected-row contrast test.
|
||||
*/
|
||||
double relativeLuminance(const QColor &color);
|
||||
|
||||
/** @brief WCAG contrast ratio between two opaque colors, from 1.0 to 21.0. */
|
||||
double contrastRatio(const QColor &first, const QColor &second);
|
||||
|
||||
/**
|
||||
* @brief Glyph color for a badge disc.
|
||||
*
|
||||
* White on the light scheme, the saturated tone darkened to an outline-dark ink
|
||||
* on the dark scheme. The dark-scheme tones fill the disc light enough that
|
||||
* white would sit near 2.7:1 at its top, so that scheme keeps the darkened ink
|
||||
* the badge had before the filled treatments shared a fill. The dark ink is
|
||||
* derived from the tone, so the theme keeps control of both ends.
|
||||
*/
|
||||
QColor glyphColor(const QColor &tone);
|
||||
|
||||
/** @brief Size of the engraved glyph for a badge of @p pixelSize, in device pixels. */
|
||||
QSize glyphSize(const QSize &pixelSize);
|
||||
|
||||
/**
|
||||
* @brief Renders a sentinel-carrying SVG as a badge.
|
||||
*
|
||||
* The disc fills the square canvas minus the ring with the same gradient the
|
||||
* sticker fill uses, the ring darkens the tone, and the sentinel is resolved to
|
||||
* the contrast glyph. The SVG is scaled to nearly the whole disc, because the
|
||||
* sentinel paths are padded inside their viewBox and a smaller box loses the
|
||||
* stroke at 16px.
|
||||
*
|
||||
* This treatment reproduces the pre-#7595 ready/not-ready art, which was a
|
||||
* filled disc. It is not a generic replacement for every semantic glyph: the
|
||||
* old add/remove icons were filled glyphs with an outline and no disc, so
|
||||
* badging them would invent a look the UI never had.
|
||||
*
|
||||
* @param svg Sentinel-carrying SVG source; `currentColor` is resolved internally.
|
||||
* @param tone Disc color, already resolved for the icon mode.
|
||||
* @param pixelSize Canvas size in device pixels; a non-square canvas centers
|
||||
* the badge and leaves the remaining area transparent.
|
||||
* @return The composited badge, or a null pixmap for empty or invalid input.
|
||||
*/
|
||||
QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize);
|
||||
} // namespace ThemeIconBadge
|
||||
|
||||
#endif // THEME_ICON_BADGE_H
|
||||
61
cockatrice/src/interface/theme_icon_fill.cpp
Normal file
61
cockatrice/src/interface/theme_icon_fill.cpp
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
#include "theme_icon_fill.h"
|
||||
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace
|
||||
{
|
||||
// Semantic tones are tuned for text. The dark-scheme ones are deliberately
|
||||
// desaturated, which reads washed out when the same color fills a whole shape,
|
||||
// so the fill gives its tone a saturation floor.
|
||||
constexpr int MIN_SATURATION = 170;
|
||||
// A tone this light belongs to the dark scheme; filling a glyph with it at full
|
||||
// value is what looks pale, so its gradient is pulled down instead of up.
|
||||
constexpr int LIGHT_TONE_VALUE = 200;
|
||||
constexpr int MIN_VALUE = 200;
|
||||
constexpr int GRADIENT_BOTTOM_DARKER = 140;
|
||||
constexpr int DARK_SCHEME_TOP_DARKER = 120;
|
||||
constexpr int DARK_SCHEME_BOTTOM_DARKER = 170;
|
||||
} // namespace
|
||||
|
||||
namespace
|
||||
{
|
||||
/** @brief Raises a tone's saturation, and optionally its value, for a filled shape. */
|
||||
QColor boost(const QColor &color, int minValue)
|
||||
{
|
||||
if (color.hue() < 0 || (color.hsvSaturation() >= MIN_SATURATION && color.value() >= minValue)) {
|
||||
return color;
|
||||
}
|
||||
QColor result = color;
|
||||
result.setHsv(color.hue(), qMax(color.hsvSaturation(), MIN_SATURATION), qMax(color.value(), minValue));
|
||||
// Return RGB so callers comparing or caching the tone do not see a spec
|
||||
// change for an unchanged color.
|
||||
return result.toRgb();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace ThemeIconFill
|
||||
{
|
||||
QColor saturated(const QColor &tone)
|
||||
{
|
||||
return boost(tone, 0);
|
||||
}
|
||||
|
||||
bool isDarkSchemeTone(const QColor &tone)
|
||||
{
|
||||
return tone.value() >= LIGHT_TONE_VALUE;
|
||||
}
|
||||
|
||||
QColor topColor(const QColor &tone)
|
||||
{
|
||||
if (isDarkSchemeTone(tone)) {
|
||||
return boost(tone, 0).darker(DARK_SCHEME_TOP_DARKER);
|
||||
}
|
||||
return boost(tone, MIN_VALUE);
|
||||
}
|
||||
|
||||
QColor bottomColor(const QColor &tone)
|
||||
{
|
||||
const int darker = isDarkSchemeTone(tone) ? DARK_SCHEME_BOTTOM_DARKER : GRADIENT_BOTTOM_DARKER;
|
||||
return boost(tone, 0).darker(darker);
|
||||
}
|
||||
} // namespace ThemeIconFill
|
||||
63
cockatrice/src/interface/theme_icon_fill.h
Normal file
63
cockatrice/src/interface/theme_icon_fill.h
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* @file theme_icon_fill.h
|
||||
* @ingroup Interface
|
||||
*/
|
||||
|
||||
#ifndef THEME_ICON_FILL_H
|
||||
#define THEME_ICON_FILL_H
|
||||
|
||||
#include <QColor>
|
||||
|
||||
/**
|
||||
* @brief Picks the two colors of a filled treatment's vertical gradient.
|
||||
*
|
||||
* The pre-#7595 icons were filled with a lighter top over a darker bottom. The
|
||||
* semantic tones are tuned for text, not for filling a whole shape; the
|
||||
* dark-scheme tones in particular are desaturated and read washed out once a
|
||||
* disc or a glyph carries them. The badge and the sticker share one fill rule so
|
||||
* they sit next to each other with the same green or red.
|
||||
*
|
||||
* The rule needs no scheme switch: a tone whose value is already high is a
|
||||
* dark-scheme tone, so its gradient is pulled down. Filling a shape with a tone
|
||||
* that light is what reads pale.
|
||||
*/
|
||||
namespace ThemeIconFill
|
||||
{
|
||||
/**
|
||||
* @brief Returns the tone with a saturation floor, for a filled shape.
|
||||
*
|
||||
* The hue and value are left alone, so a light-scheme tone stays as dark as the
|
||||
* line glyph next to it, and an achromatic tone stays gray.
|
||||
*
|
||||
* @param tone Glyph color the treatment is rendered with.
|
||||
* @return The tone, saturated enough to carry a whole shape.
|
||||
*/
|
||||
QColor saturated(const QColor &tone);
|
||||
|
||||
/**
|
||||
* @brief True when @p tone is a light tone, as the dark scheme's palette is.
|
||||
*
|
||||
* The fill branches on this: a tone this light is darkened for a filled shape,
|
||||
* and the badge keeps its darkened ink on it instead of the light scheme's
|
||||
* white glyph.
|
||||
*
|
||||
* @param tone Glyph color the treatment is rendered with.
|
||||
*/
|
||||
bool isDarkSchemeTone(const QColor &tone);
|
||||
|
||||
/**
|
||||
* @brief Returns the top color of the fill gradient.
|
||||
* @param tone Glyph color the treatment is rendered with.
|
||||
* @return The lighter end of the gradient.
|
||||
*/
|
||||
QColor topColor(const QColor &tone);
|
||||
|
||||
/**
|
||||
* @brief Returns the bottom color of the fill gradient.
|
||||
* @param tone Glyph color the treatment is rendered with.
|
||||
* @return The darker end of the gradient.
|
||||
*/
|
||||
QColor bottomColor(const QColor &tone);
|
||||
} // namespace ThemeIconFill
|
||||
|
||||
#endif // THEME_ICON_FILL_H
|
||||
87
cockatrice/src/interface/theme_icon_sticker.cpp
Normal file
87
cockatrice/src/interface/theme_icon_sticker.cpp
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#include "theme_icon_sticker.h"
|
||||
|
||||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
#include "theme_icon_fill.h"
|
||||
|
||||
#include <QLinearGradient>
|
||||
#include <QPainter>
|
||||
#include <QPointF>
|
||||
#include <QRectF>
|
||||
#include <QSvgRenderer>
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace
|
||||
{
|
||||
// The pre-#7595 add/remove art filled about 80% of the canvas: the sentinel
|
||||
// paths are padded inside their 24-unit viewBox, so the drawing box has to be
|
||||
// slightly larger than the canvas for the ink to reach that far.
|
||||
constexpr qreal STICKER_SCALE = 1.06;
|
||||
// Old arm thickness was about a fifth of the canvas, with a rim near 5%. In the
|
||||
// 24-unit viewBox that is a 4.5-unit band with a 0.9-unit rim on each side.
|
||||
constexpr qreal STICKER_STROKE_WIDTH = 4.5;
|
||||
constexpr qreal STICKER_OUTLINE_WIDTH = 0.9;
|
||||
// The old art's outline was black. Darkening by 300 reads as black on a light
|
||||
// scheme (the shipped tones are already dark there) while staying a visible dark
|
||||
// rim on a dark one, where a neutral black would vanish into the window.
|
||||
constexpr int OUTLINE_DARKER = 300;
|
||||
} // namespace
|
||||
|
||||
namespace ThemeIconSticker
|
||||
{
|
||||
QColor outlineColor(const QColor &tone)
|
||||
{
|
||||
return ThemeIconFill::saturated(tone).darker(OUTLINE_DARKER);
|
||||
}
|
||||
|
||||
QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize)
|
||||
{
|
||||
if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QPixmap pixmap(pixelSize);
|
||||
pixmap.fill(Qt::transparent);
|
||||
|
||||
const qreal side = qMin(pixelSize.width(), pixelSize.height());
|
||||
const qreal box = side * STICKER_SCALE;
|
||||
const QRectF glyphRect(QPointF((pixelSize.width() - box) / 2.0, (pixelSize.height() - box) / 2.0),
|
||||
QSizeF(box, box));
|
||||
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
|
||||
// Outline first, at the band width plus one rim on each side; the fill pass
|
||||
// then covers the middle and leaves only the rim visible.
|
||||
const QByteArray outlineSvg =
|
||||
resolveSvgStrokeWidth(resolveSvgColorSentinel(svg, outlineColor(tone).name(QColor::HexRgb).toLatin1()),
|
||||
STICKER_STROKE_WIDTH + 2.0 * STICKER_OUTLINE_WIDTH);
|
||||
QSvgRenderer outlineRenderer(outlineSvg);
|
||||
if (outlineRenderer.isValid()) {
|
||||
outlineRenderer.render(&painter, glyphRect);
|
||||
}
|
||||
|
||||
// The fill needs its own pass so the gradient can be clipped to the glyph:
|
||||
// painting it onto the composited pixmap would tint the outline as well.
|
||||
QPixmap fillPixmap(pixelSize);
|
||||
fillPixmap.fill(Qt::transparent);
|
||||
{
|
||||
QPainter fillPainter(&fillPixmap);
|
||||
fillPainter.setRenderHint(QPainter::Antialiasing, true);
|
||||
|
||||
const QByteArray fillSvg = resolveSvgStrokeWidth(
|
||||
resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()), STICKER_STROKE_WIDTH);
|
||||
QSvgRenderer fillRenderer(fillSvg);
|
||||
if (fillRenderer.isValid()) {
|
||||
fillRenderer.render(&fillPainter, glyphRect);
|
||||
fillPainter.setCompositionMode(QPainter::CompositionMode_SourceIn);
|
||||
QLinearGradient gradient(glyphRect.topLeft(), glyphRect.bottomLeft());
|
||||
gradient.setColorAt(0.0, ThemeIconFill::topColor(tone));
|
||||
gradient.setColorAt(1.0, ThemeIconFill::bottomColor(tone));
|
||||
fillPainter.fillRect(QRectF(QPointF(0, 0), QSizeF(pixelSize)), gradient);
|
||||
}
|
||||
}
|
||||
painter.drawPixmap(0, 0, fillPixmap);
|
||||
|
||||
return pixmap;
|
||||
}
|
||||
} // namespace ThemeIconSticker
|
||||
61
cockatrice/src/interface/theme_icon_sticker.h
Normal file
61
cockatrice/src/interface/theme_icon_sticker.h
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* @file theme_icon_sticker.h
|
||||
* @ingroup Interface
|
||||
*/
|
||||
|
||||
#ifndef THEME_ICON_STICKER_H
|
||||
#define THEME_ICON_STICKER_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QColor>
|
||||
#include <QPixmap>
|
||||
#include <QSize>
|
||||
|
||||
/**
|
||||
* @brief Paints the sticker treatment of a `currentColor` glyph.
|
||||
*
|
||||
* The pre-#7595 add, remove and delete icons were filled shapes with a black
|
||||
* outline and no disc. A tint alone cannot reproduce that: it produces a
|
||||
* hairline, and a badge disc is a different icon language. The sticker
|
||||
* treatment rebuilds the look from the same sentinel art by drawing the glyph
|
||||
* twice, once with a dark outline and once with a vertical tone gradient on top.
|
||||
*
|
||||
* A sticker is louder than a line, which suits an add or a destructive action.
|
||||
* It needs a size where the tone band is at least two device pixels wide.
|
||||
* Callers opt in. Navigation and menus keep the line treatment.
|
||||
*
|
||||
* Everything here works in device pixels, like the badge treatment: the engine
|
||||
* renders at the size it already computed and attaches the device pixel ratio
|
||||
* afterwards.
|
||||
*/
|
||||
namespace ThemeIconSticker
|
||||
{
|
||||
/**
|
||||
* @brief Returns the dark rim color the sticker is painted with.
|
||||
*
|
||||
* The old art had a black outline. Deriving it from the tone reads as black on a
|
||||
* light scheme without a literal, and stays visible on a dark one, where a pure
|
||||
* black rim would disappear into the window.
|
||||
*
|
||||
* @param tone Glyph color the sticker is rendered with.
|
||||
* @return A much darker version of the tone.
|
||||
*/
|
||||
QColor outlineColor(const QColor &tone);
|
||||
|
||||
/**
|
||||
* @brief Renders a sentinel-carrying SVG as a filled glyph with a black outline.
|
||||
*
|
||||
* The glyph is drawn larger than the canvas so the padded sentinel paths fill
|
||||
* the icon the way the old art did, with a vertical tone gradient as the fill
|
||||
* and a near-black rim. Authored art is never drawn through this path.
|
||||
*
|
||||
* @param svg Sentinel-carrying SVG source; `currentColor` is resolved internally.
|
||||
* @param tone Glyph color, already resolved for the icon mode.
|
||||
* @param pixelSize Canvas size in device pixels; a non-square canvas centers
|
||||
* the glyph and leaves the remaining area transparent.
|
||||
* @return The composited sticker, or a null pixmap for empty or invalid input.
|
||||
*/
|
||||
QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize);
|
||||
} // namespace ThemeIconSticker
|
||||
|
||||
#endif // THEME_ICON_STICKER_H
|
||||
|
|
@ -639,14 +639,23 @@ QColor ThemeManager::appColor(AppColor::Role role) const
|
|||
// unconditionally keeps every Qt version rendering identically.
|
||||
const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight);
|
||||
|
||||
if (role == AppColor::AccentSoft) {
|
||||
constexpr int SOFT_SATURATION_PERCENT = 70;
|
||||
constexpr int SOFT_LIGHTNESS_OFFSET = 60;
|
||||
switch (role) {
|
||||
case AppColor::AccentStrong:
|
||||
return accent;
|
||||
case AppColor::AccentSoft: {
|
||||
constexpr int SOFT_SATURATION_PERCENT = 70;
|
||||
constexpr int SOFT_LIGHTNESS_OFFSET = 60;
|
||||
|
||||
// Light end of the gradient: same hue, softened and lightened
|
||||
return QColor::fromHsl(qMax(0, accent.hslHue()),
|
||||
qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255),
|
||||
qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255));
|
||||
// Light end of the gradient: same hue, softened and lightened
|
||||
return QColor::fromHsl(qMax(0, accent.hslHue()),
|
||||
qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255),
|
||||
qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255));
|
||||
}
|
||||
case AppColor::Positive:
|
||||
case AppColor::Negative:
|
||||
// Semantic colors are independent of the accent: a missing role must
|
||||
// not silently paint "remove" in the theme's highlight color.
|
||||
return defaultAppColor(role, isDarkModeActive());
|
||||
}
|
||||
|
||||
return accent;
|
||||
|
|
|
|||
|
|
@ -147,7 +147,8 @@ public:
|
|||
void previewFonts(const FontConfig &cfg);
|
||||
|
||||
// Resolves an application color role: the theme's stored [AppColors] value
|
||||
// when present, otherwise a palette-accent-derived fallback.
|
||||
// when present, otherwise a palette-accent-derived fallback for accents and
|
||||
// a scheme-appropriate semantic default for positive / negative.
|
||||
QColor appColor(AppColor::Role role) const;
|
||||
/** @brief Resolves an application font role: the theme's stored [Fonts]
|
||||
* value when present, otherwise the application font. Roles that
|
||||
|
|
|
|||
|
|
@ -83,12 +83,14 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent
|
|||
&DeckEditorDatabaseDisplayWidget::onRelatedCardClicked);
|
||||
|
||||
aAddCard = new QAction(QString(), this);
|
||||
aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right")));
|
||||
aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"), AppColor::Positive));
|
||||
connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck);
|
||||
auto *tbAddCard = new QToolButton(this);
|
||||
tbAddCard->setDefaultAction(aAddCard);
|
||||
|
||||
aAddCardToSideboard = new QAction(QString(), this);
|
||||
// Sideboard is a destination, not a positive state: its distinct glyph
|
||||
// keeps the target legible without competing with the green main-deck add.
|
||||
aAddCardToSideboard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right_sideboard")));
|
||||
connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard);
|
||||
auto *tbAddCardToSideboard = new QToolButton(this);
|
||||
|
|
|
|||
|
|
@ -248,19 +248,19 @@ void DeckEditorDeckDockWidget::createDeckDock()
|
|||
&DeckEditorDeckDockWidget::applyActiveGroupCriteria);
|
||||
|
||||
aIncrement = new QAction(QString(), this);
|
||||
aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment")));
|
||||
aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker));
|
||||
connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection);
|
||||
auto *tbIncrement = new QToolButton(this);
|
||||
tbIncrement->setDefaultAction(aIncrement);
|
||||
|
||||
aDecrement = new QAction(QString(), this);
|
||||
aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement")));
|
||||
aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker));
|
||||
connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection);
|
||||
auto *tbDecrement = new QToolButton(this);
|
||||
tbDecrement->setDefaultAction(aDecrement);
|
||||
|
||||
aRemoveCard = new QAction(QString(), this);
|
||||
aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row")));
|
||||
aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
|
||||
connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard);
|
||||
auto *tbRemoveCard = new QToolButton(this);
|
||||
tbRemoveCard->setDefaultAction(aRemoveCard);
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ void DeckEditorFilterDockWidget::createFiltersDock()
|
|||
connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter);
|
||||
|
||||
aClearFilterOne = new QAction(QString(), this);
|
||||
aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement")));
|
||||
aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker));
|
||||
connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne);
|
||||
|
||||
aClearFilterAll = new QAction(QString(), this);
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
|
|||
previousHosts = new QComboBox(this);
|
||||
|
||||
btnDeleteServer = new QPushButton(this);
|
||||
btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row")));
|
||||
btnDeleteServer->setIcon(
|
||||
loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
|
||||
btnDeleteServer->setToolTip(tr("Delete the currently selected saved server"));
|
||||
btnDeleteServer->setFixedWidth(30);
|
||||
|
||||
|
|
|
|||
|
|
@ -108,10 +108,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul
|
|||
&DlgEditTokens::tokenSelectionChanged);
|
||||
|
||||
QAction *aAddToken = new QAction(tr("Add token"), this);
|
||||
aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment")));
|
||||
aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker));
|
||||
connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken);
|
||||
QAction *aRemoveToken = new QAction(tr("Remove token"), this);
|
||||
aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement")));
|
||||
aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker));
|
||||
connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken);
|
||||
|
||||
auto *databaseToolBar = new QToolBar;
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
|
|||
previousHosts = new QComboBox(this);
|
||||
|
||||
btnDeleteServer = new QPushButton(this);
|
||||
btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row")));
|
||||
btnDeleteServer->setIcon(
|
||||
loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
|
||||
btnDeleteServer->setToolTip(tr("Delete the currently selected saved server"));
|
||||
btnDeleteServer->setFixedWidth(30);
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
|
|||
}
|
||||
|
||||
aAdd = new QAction(this);
|
||||
aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment")));
|
||||
aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker));
|
||||
connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL);
|
||||
|
||||
aEdit = new QAction(this);
|
||||
|
|
@ -88,7 +88,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
|
|||
connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL);
|
||||
|
||||
aRemove = new QAction(this);
|
||||
aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement")));
|
||||
aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker));
|
||||
connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL);
|
||||
|
||||
aRateLimit = new QAction(this);
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ MessagesSettingsPage::MessagesSettingsPage()
|
|||
}
|
||||
|
||||
aAdd = new QAction(this);
|
||||
aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment")));
|
||||
aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker));
|
||||
connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd);
|
||||
|
||||
aEdit = new QAction(this);
|
||||
|
|
@ -158,7 +158,7 @@ MessagesSettingsPage::MessagesSettingsPage()
|
|||
connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit);
|
||||
|
||||
aRemove = new QAction(this);
|
||||
aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement")));
|
||||
aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker));
|
||||
connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove);
|
||||
|
||||
auto *messageToolBar = new QToolBar;
|
||||
|
|
|
|||
|
|
@ -199,7 +199,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
|
|||
aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
|
||||
connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder);
|
||||
aDeleteLocalDeck = new QAction(this);
|
||||
aDeleteLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row")));
|
||||
aDeleteLocalDeck->setIcon(
|
||||
loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
|
||||
connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck);
|
||||
|
||||
aOpenDecksFolder = new QAction(this);
|
||||
|
|
@ -217,7 +218,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
|
|||
aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
|
||||
connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder);
|
||||
aDeleteRemoteDeck = new QAction(this);
|
||||
aDeleteRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row")));
|
||||
aDeleteRemoteDeck->setIcon(
|
||||
loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
|
||||
connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck);
|
||||
|
||||
aShareDecks = new QAction(this);
|
||||
|
|
|
|||
|
|
@ -155,7 +155,8 @@ QGroupBox *TabReplays::createLeftLayout()
|
|||
aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
|
||||
connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder);
|
||||
aDeleteLocalReplay = new QAction(this);
|
||||
aDeleteLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row")));
|
||||
aDeleteLocalReplay->setIcon(
|
||||
loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
|
||||
connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay);
|
||||
|
||||
aOpenReplaysFolder = new QAction(this);
|
||||
|
|
@ -217,7 +218,8 @@ QGroupBox *TabReplays::createRightLayout()
|
|||
aKeep->setIcon(loadThemedIcon(QStringLiteral("icons/lock")));
|
||||
connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay);
|
||||
aDeleteRemoteReplay = new QAction(this);
|
||||
aDeleteRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row")));
|
||||
aDeleteRemoteReplay->setIcon(
|
||||
loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker));
|
||||
connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay);
|
||||
aGetReplayCode = new QAction(this);
|
||||
aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share")));
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
|
|||
|
||||
clearFilterWidget = new QToolButton();
|
||||
clearFilterWidget->setFixedSize(32, 32);
|
||||
clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete")));
|
||||
clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"), AppColor::Negative));
|
||||
connect(clearFilterWidget, &QToolButton::clicked, this, [this] {
|
||||
filterModel->blockSignals(true);
|
||||
filterModel->filterTree()->blockSignals(true);
|
||||
|
|
|
|||
|
|
@ -71,4 +71,6 @@ Accent = #4a82e6
|
|||
[AppColors]
|
||||
AccentStrong = #3a6fd6
|
||||
AccentSoft = #8fb8f2
|
||||
Positive = #6dd58c
|
||||
Negative = #ff8a80
|
||||
|
||||
|
|
|
|||
|
|
@ -71,4 +71,6 @@ Accent = #2f6fd6
|
|||
[AppColors]
|
||||
AccentStrong = #3a6fd6
|
||||
AccentSoft = #8fb8f2
|
||||
Positive = #1b7f3b
|
||||
Negative = #b3261e
|
||||
|
||||
|
|
|
|||
|
|
@ -71,3 +71,5 @@ Accent = #ff1e1e1e
|
|||
[AppColors]
|
||||
AccentStrong = #ff139740
|
||||
AccentSoft = #ffc9fd62
|
||||
Positive = #6dd58c
|
||||
Negative = #ff8a80
|
||||
|
|
|
|||
|
|
@ -71,3 +71,5 @@ Accent = #fff0f0f0
|
|||
[AppColors]
|
||||
AccentStrong = #ff139740
|
||||
AccentSoft = #ffc9fd62
|
||||
Positive = #1b7f3b
|
||||
Negative = #b3261e
|
||||
|
|
|
|||
|
|
@ -71,4 +71,6 @@ Accent = #c9995a
|
|||
[AppColors]
|
||||
AccentStrong = #b7823b
|
||||
AccentSoft = #e4c58f
|
||||
Positive = #6dd58c
|
||||
Negative = #ff8a80
|
||||
|
||||
|
|
|
|||
|
|
@ -71,4 +71,6 @@ Accent = #a5712f
|
|||
[AppColors]
|
||||
AccentStrong = #b7823b
|
||||
AccentSoft = #e4c58f
|
||||
Positive = #1b7f3b
|
||||
Negative = #b3261e
|
||||
|
||||
|
|
|
|||
|
|
@ -71,4 +71,6 @@ Accent = #6a4df0
|
|||
[AppColors]
|
||||
AccentStrong = #5b3ee0
|
||||
AccentSoft = #a28bf7
|
||||
Positive = #6dd58c
|
||||
Negative = #ff8a80
|
||||
|
||||
|
|
|
|||
|
|
@ -71,4 +71,6 @@ Accent = #5a3ee2
|
|||
[AppColors]
|
||||
AccentStrong = #5b3ee0
|
||||
AccentSoft = #a28bf7
|
||||
Positive = #1b7f3b
|
||||
Negative = #b3261e
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
# The System theme derives all of its colors from the platform palette
|
||||
# (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette
|
||||
# overrides. Only the identity app colors are provided here, so buttons keep
|
||||
# their classic greens while the actual palette stays native to the OS.
|
||||
# overrides. Only the identity and semantic app colors are provided here, so
|
||||
# buttons keep their classic greens while the actual palette stays native to
|
||||
# the OS.
|
||||
[AppColors]
|
||||
AccentStrong = #ff148c3c
|
||||
AccentSoft = #ff78c850
|
||||
Positive = #ff6dd58c
|
||||
Negative = #ffff8a80
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
# The System theme derives all of its colors from the platform palette
|
||||
# (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette
|
||||
# overrides. Only the identity app colors are provided here, so buttons keep
|
||||
# their classic greens while the actual palette stays native to the OS.
|
||||
# overrides. Only the identity and semantic app colors are provided here, so
|
||||
# buttons keep their classic greens while the actual palette stays native to
|
||||
# the OS.
|
||||
[AppColors]
|
||||
AccentStrong = #ff148c3c
|
||||
AccentSoft = #ff78c850
|
||||
Positive = #ff1b7f3b
|
||||
Negative = #ffb3261e
|
||||
|
|
|
|||
|
|
@ -71,4 +71,6 @@ Accent = #8fa6c0
|
|||
[AppColors]
|
||||
AccentStrong = #54687e
|
||||
AccentSoft = #9db0c4
|
||||
Positive = #6dd58c
|
||||
Negative = #ff8a80
|
||||
|
||||
|
|
|
|||
|
|
@ -71,4 +71,6 @@ Accent = #54687e
|
|||
[AppColors]
|
||||
AccentStrong = #54687e
|
||||
AccentSoft = #9db0c4
|
||||
Positive = #1b7f3b
|
||||
Negative = #b3261e
|
||||
|
||||
|
|
|
|||
|
|
@ -42,3 +42,46 @@ QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &colo
|
|||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
QByteArray resolveSvgStrokeWidth(const QByteArray &svg, double width)
|
||||
{
|
||||
constexpr QByteArrayView ATTRIBUTE = "stroke-width";
|
||||
constexpr QByteArrayView DOUBLE_QUOTE = "\"";
|
||||
constexpr QByteArrayView SINGLE_QUOTE = "'";
|
||||
const QByteArray value = QByteArray::number(width, 'f', 2);
|
||||
|
||||
QByteArray resolved;
|
||||
resolved.reserve(svg.size());
|
||||
|
||||
qsizetype cursor = 0;
|
||||
while (true) {
|
||||
const qsizetype start = svg.indexOf(ATTRIBUTE, cursor);
|
||||
if (start < 0) {
|
||||
resolved.append(svg.sliced(cursor));
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Only an XML attribute is rewritten. A CSS declaration such as
|
||||
// `style="stroke-width:2"` has no `=` right after the name, so it stays
|
||||
// untouched and the scan moves past it.
|
||||
qsizetype nameEnd = start + ATTRIBUTE.size();
|
||||
while (nameEnd < svg.size() && svg.at(nameEnd) == ' ') {
|
||||
++nameEnd;
|
||||
}
|
||||
const bool isAttribute = nameEnd < svg.size() && svg.at(nameEnd) == '=' && nameEnd + 1 < svg.size();
|
||||
const char quoteChar = isAttribute ? svg.at(nameEnd + 1) : '\0';
|
||||
const bool quoted = quoteChar == '"' || quoteChar == '\'';
|
||||
const qsizetype close = quoted ? svg.indexOf(quoteChar == '"' ? DOUBLE_QUOTE : SINGLE_QUOTE, nameEnd + 2) : -1;
|
||||
if (close < 0) {
|
||||
resolved.append(svg.sliced(cursor, start + ATTRIBUTE.size() - cursor));
|
||||
cursor = start + ATTRIBUTE.size();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep the opening quote, replace the value, and resume at the closing
|
||||
// quote so the rest of the document is copied verbatim.
|
||||
resolved.append(svg.sliced(cursor, nameEnd + 2 - cursor));
|
||||
resolved.append(value);
|
||||
cursor = close;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,4 +28,21 @@
|
|||
*/
|
||||
QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color);
|
||||
|
||||
/**
|
||||
* @brief Rewrites the value of every `stroke-width` attribute in authored SVG.
|
||||
*
|
||||
* A treatment can need a heavier or lighter stroke than the one authored for a
|
||||
* line icon at text size. Like the sentinel resolver, this rewriter works on the
|
||||
* bytes and understands only the XML attribute form, in either quote style
|
||||
* (`stroke-width="2"` or `stroke-width='2'`): a CSS declaration such as
|
||||
* `style="stroke-width:2"` is left untouched. Documents without the attribute
|
||||
* are returned unchanged.
|
||||
*
|
||||
* @param svg SVG source bytes, expected to be UTF-8. Invalid UTF-8 is preserved
|
||||
* byte-for-byte.
|
||||
* @param width Replacement width in the document's user units.
|
||||
* @return The rewritten SVG source; the input is left untouched.
|
||||
*/
|
||||
QByteArray resolveSvgStrokeWidth(const QByteArray &svg, double width);
|
||||
|
||||
#endif // SVG_RECOLOR_H
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ set(oracle_SOURCES
|
|||
../cockatrice/src/interface/pixel_map_generator.cpp
|
||||
../cockatrice/src/interface/theme_config.cpp
|
||||
../cockatrice/src/interface/theme_icon.cpp
|
||||
../cockatrice/src/interface/theme_icon_badge.cpp
|
||||
../cockatrice/src/interface/theme_icon_fill.cpp
|
||||
../cockatrice/src/interface/theme_icon_sticker.cpp
|
||||
../cockatrice/src/interface/theme_manager.cpp
|
||||
${VERSION_STRING_CPP}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -63,4 +63,57 @@ TEST(SvgRecolor, NoSentinelIsANoOp)
|
|||
EXPECT_EQ(resolveSvgColorSentinel(svg, "#ffffff"), svg);
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, RewritesEveryAttribute)
|
||||
{
|
||||
const QByteArray svg = R"(<svg stroke-width="2"><path stroke-width="1.5"/><path stroke-width='3'/></svg>)";
|
||||
|
||||
const QByteArray result = resolveSvgStrokeWidth(svg, 4.25);
|
||||
|
||||
EXPECT_EQ(result.count(R"(stroke-width="4.25")"), 2);
|
||||
EXPECT_EQ(result.count(R"(stroke-width='4.25')"), 1);
|
||||
EXPECT_FALSE(result.contains("1.5"));
|
||||
EXPECT_FALSE(result.contains("3'/"));
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, LeavesCssDeclarationsAlone)
|
||||
{
|
||||
const QByteArray svg = R"(<svg><path style="stroke-width:2"/><path stroke-width="2"/></svg>)";
|
||||
|
||||
const QByteArray result = resolveSvgStrokeWidth(svg, 3.0);
|
||||
|
||||
EXPECT_TRUE(result.contains(R"(style="stroke-width:2")"));
|
||||
EXPECT_EQ(result.count(R"(stroke-width="3.00")"), 1);
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, NoAttributeIsANoOp)
|
||||
{
|
||||
const QByteArray svg = R"(<svg stroke-linecap="round"><path d="M0 0h24"/></svg>)";
|
||||
|
||||
EXPECT_EQ(resolveSvgStrokeWidth(svg, 3.0), svg);
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, DoesNotModifyTheInputAndPreservesBytes)
|
||||
{
|
||||
QByteArray svg = "<svg>";
|
||||
svg.append("\xff\xfe");
|
||||
svg.append(R"(<path stroke-width="2"/></svg>)");
|
||||
const QByteArray original = svg;
|
||||
|
||||
const QByteArray result = resolveSvgStrokeWidth(svg, 3.5);
|
||||
|
||||
EXPECT_EQ(svg, original);
|
||||
EXPECT_TRUE(result.contains("\xff\xfe"));
|
||||
EXPECT_TRUE(result.contains(R"(stroke-width="3.50")"));
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, TruncatedAttributeTerminates)
|
||||
{
|
||||
// A quote that is never closed must not over-read or hang.
|
||||
const QByteArray svg = R"(<svg><path stroke-width="2/></svg>)";
|
||||
|
||||
const QByteArray result = resolveSvgStrokeWidth(svg, 3.0);
|
||||
|
||||
EXPECT_TRUE(result.startsWith("<svg>"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ cockatrice_add_test(
|
|||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/pixel_map_generator.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_badge.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_fill.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_sticker.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp
|
||||
|
|
@ -13,6 +16,8 @@ cockatrice_add_test(
|
|||
${VERSION_STRING_CPP}
|
||||
theme_migration_test.cpp
|
||||
theme_icon_render_test.cpp
|
||||
icon_tint_preview_test.cpp
|
||||
icon_treatment_preview_test.cpp
|
||||
LIBS
|
||||
libcockatrice_settings
|
||||
libcockatrice_network
|
||||
|
|
|
|||
227
tests/theme_migration/icon_tint_preview_test.cpp
Normal file
227
tests/theme_migration/icon_tint_preview_test.cpp
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
#include "interface/theme_config.h"
|
||||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QByteArray>
|
||||
#include <QColor>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFont>
|
||||
#include <QGuiApplication>
|
||||
#include <QImage>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPointF>
|
||||
#include <QSize>
|
||||
#include <QStringList>
|
||||
#include <QStringLiteral>
|
||||
#include <QSvgRenderer>
|
||||
#include <gtest/gtest.h>
|
||||
#include <iterator>
|
||||
|
||||
namespace
|
||||
{
|
||||
/**
|
||||
* @brief Creates the GUI application QPixmap needs, using the offscreen plugin.
|
||||
*
|
||||
* The theme-migration binary is headless; creating this lazily keeps the other
|
||||
* test files free of a display dependency.
|
||||
*/
|
||||
void ensureGuiApplication()
|
||||
{
|
||||
static int argc = 1;
|
||||
static char name[] = "icon_tint_preview_test";
|
||||
static char *argv[] = {name, nullptr};
|
||||
// Leaked on purpose: a function-local static QApplication is destroyed before Qt's
|
||||
// own library statics at exit, and the network module's destructors then run
|
||||
// against a torn-down QObject layer (SEGV under AddressSanitizer).
|
||||
static QApplication *app = new QApplication(argc, argv);
|
||||
Q_UNUSED(app);
|
||||
}
|
||||
|
||||
QString resourcesDir()
|
||||
{
|
||||
return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources"));
|
||||
}
|
||||
|
||||
/** @brief Reads an authored icon from the theme resource directory. */
|
||||
QByteArray readIconSvg(const char *name)
|
||||
{
|
||||
QFile file(QDir(resourcesDir()).absoluteFilePath(QStringLiteral("icons/%1.svg").arg(QString::fromLatin1(name))));
|
||||
if (!file.open(QFile::ReadOnly)) {
|
||||
return {};
|
||||
}
|
||||
return file.readAll();
|
||||
}
|
||||
|
||||
/** @brief Renders a pixmap at device size and tags it with the ratio. */
|
||||
template <typename Render> QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render)
|
||||
{
|
||||
QPixmap pixmap = render(logicalSize * dpr);
|
||||
pixmap.setDevicePixelRatio(dpr);
|
||||
return pixmap;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* @brief Writes one semantic-tint sheet per shipped palette theme.
|
||||
*
|
||||
* The theme files are the only place the shipped tones live, so each sheet is
|
||||
* rendered from the palette the theme actually ships: both semantic families,
|
||||
* three glyphs each, at the two sizes the UI uses, on the window and button
|
||||
* surfaces the contrast is measured against. A disabled row closes each family
|
||||
* so the enabled/disabled order is visible next to the tone that carries it.
|
||||
*/
|
||||
TEST(IconTintPreview, WritesOneSheetPerShippedTheme)
|
||||
{
|
||||
const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR");
|
||||
if (outDir.isEmpty()) {
|
||||
GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs";
|
||||
}
|
||||
ensureGuiApplication();
|
||||
ASSERT_TRUE(QDir().mkpath(outDir));
|
||||
|
||||
constexpr int MARGIN = 24;
|
||||
constexpr int LABEL_WIDTH = 190;
|
||||
constexpr int SLOT = 36;
|
||||
constexpr int ROW_HEIGHT = 56;
|
||||
constexpr int PANEL_TITLE = 26;
|
||||
constexpr int PANEL_PADDING = 12;
|
||||
constexpr qreal DPR = 2.0;
|
||||
constexpr int SIZES[] = {16, 24};
|
||||
|
||||
struct Family
|
||||
{
|
||||
const char *name;
|
||||
AppColor::Role role;
|
||||
const char *glyphs[3];
|
||||
};
|
||||
const QList<Family> families = {
|
||||
{"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}},
|
||||
{"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}},
|
||||
};
|
||||
const QStringList themes = {
|
||||
QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"),
|
||||
QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"),
|
||||
};
|
||||
|
||||
const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) {
|
||||
QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1()));
|
||||
QPixmap pixmap(size);
|
||||
pixmap.fill(Qt::transparent);
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
renderer.render(&painter);
|
||||
return pixmap;
|
||||
};
|
||||
|
||||
const int glyphCount = static_cast<int>(std::size(families.first().glyphs));
|
||||
const int columnCount = glyphCount * static_cast<int>(std::size(SIZES));
|
||||
const int panelWidth = LABEL_WIDTH + columnCount * SLOT + PANEL_PADDING;
|
||||
const int panelHeight = PANEL_TITLE + static_cast<int>(families.size()) * 2 * ROW_HEIGHT + PANEL_PADDING;
|
||||
const int logicalWidth = 2 * MARGIN + panelWidth;
|
||||
const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN;
|
||||
|
||||
for (const QString &theme : themes) {
|
||||
for (const bool dark : {false, true}) {
|
||||
const QString file =
|
||||
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR))
|
||||
.absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml")
|
||||
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||||
const PaletteConfig config = PaletteConfig::fromFile(file);
|
||||
ASSERT_TRUE(config.hasPalette()) << theme.toStdString();
|
||||
|
||||
const QPalette palette = config.apply(QPalette());
|
||||
const QColor window = palette.color(QPalette::Active, QPalette::Window);
|
||||
const QColor button = palette.color(QPalette::Active, QPalette::Button);
|
||||
const QColor text = palette.color(QPalette::Active, QPalette::WindowText);
|
||||
const QColor disabled = palette.color(QPalette::Disabled, QPalette::WindowText);
|
||||
|
||||
QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)),
|
||||
QImage::Format_ARGB32_Premultiplied);
|
||||
image.fill(window);
|
||||
QPainter painter(&image);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
painter.scale(DPR, DPR);
|
||||
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold));
|
||||
painter.drawText(QPointF(MARGIN, MARGIN),
|
||||
QStringLiteral("%1 — %2 scheme, semantic tints")
|
||||
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||||
|
||||
struct Surface
|
||||
{
|
||||
const char *name;
|
||||
QColor background;
|
||||
};
|
||||
const QList<Surface> surfaces = {
|
||||
{"Window surface", window},
|
||||
{"Button surface", button},
|
||||
};
|
||||
|
||||
qreal panelTop = MARGIN + 30;
|
||||
for (const Surface &surface : surfaces) {
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(surface.background);
|
||||
painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8);
|
||||
|
||||
const qreal contentLeft = MARGIN + PANEL_PADDING;
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold));
|
||||
painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name));
|
||||
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 8));
|
||||
for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) {
|
||||
const qreal x = contentLeft + LABEL_WIDTH + static_cast<qreal>(sizeIndex) * glyphCount * SLOT;
|
||||
painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14),
|
||||
QStringLiteral("%1 px").arg(SIZES[sizeIndex]));
|
||||
}
|
||||
|
||||
qreal y = panelTop + PANEL_TITLE + 20;
|
||||
for (const Family &family : families) {
|
||||
const QColor tone = config.appColors.value(family.role, defaultAppColor(family.role, dark));
|
||||
ASSERT_TRUE(tone.isValid()) << theme.toStdString() << " " << family.name;
|
||||
for (int state = 0; state < 2; ++state) {
|
||||
const bool isDisabled = state == 1;
|
||||
const QColor ink = isDisabled ? disabled : tone;
|
||||
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold));
|
||||
painter.drawText(
|
||||
QPointF(contentLeft, y + 14),
|
||||
QStringLiteral("%1%2").arg(QString::fromLatin1(family.name),
|
||||
isDisabled ? QStringLiteral(" / disabled") : QString()));
|
||||
painter.setFont(QFont(QStringLiteral("Mono"), 7));
|
||||
painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb));
|
||||
|
||||
for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) {
|
||||
const int side = SIZES[sizeIndex];
|
||||
const QSize size(side, side);
|
||||
for (int glyphIndex = 0; glyphIndex < glyphCount; ++glyphIndex) {
|
||||
const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]);
|
||||
ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex];
|
||||
const QPixmap pixmap = renderAtDpr(
|
||||
size, DPR, [&](const QSize &pixelSize) { return renderLine(svg, ink, pixelSize); });
|
||||
const qreal x = contentLeft + LABEL_WIDTH +
|
||||
static_cast<qreal>(sizeIndex * glyphCount + glyphIndex) * SLOT +
|
||||
(SLOT - side) / 2.0;
|
||||
painter.drawPixmap(QPointF(x, y + (ROW_HEIGHT - side) / 2.0 - 2), pixmap);
|
||||
}
|
||||
}
|
||||
y += ROW_HEIGHT;
|
||||
}
|
||||
}
|
||||
panelTop += panelHeight + 24;
|
||||
}
|
||||
|
||||
painter.end();
|
||||
const QString path = QDir(outDir).absoluteFilePath(
|
||||
QStringLiteral("semantic-tint-preview-%1-%2.png")
|
||||
.arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||||
ASSERT_TRUE(image.save(path)) << path.toStdString();
|
||||
}
|
||||
}
|
||||
}
|
||||
245
tests/theme_migration/icon_treatment_preview_test.cpp
Normal file
245
tests/theme_migration/icon_treatment_preview_test.cpp
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
#include "interface/theme_config.h"
|
||||
#include "interface/theme_icon_badge.h"
|
||||
#include "interface/theme_icon_fill.h"
|
||||
#include "interface/theme_icon_sticker.h"
|
||||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QByteArray>
|
||||
#include <QColor>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFont>
|
||||
#include <QGuiApplication>
|
||||
#include <QImage>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPointF>
|
||||
#include <QSize>
|
||||
#include <QStringList>
|
||||
#include <QStringLiteral>
|
||||
#include <QSvgRenderer>
|
||||
#include <gtest/gtest.h>
|
||||
#include <iterator>
|
||||
|
||||
namespace
|
||||
{
|
||||
/**
|
||||
* @brief Creates the GUI application QPixmap needs, using the offscreen plugin.
|
||||
*
|
||||
* The theme-migration binary is headless; creating this lazily keeps the other
|
||||
* test files free of a display dependency.
|
||||
*/
|
||||
void ensureGuiApplication()
|
||||
{
|
||||
static int argc = 1;
|
||||
static char name[] = "icon_treatment_preview_test";
|
||||
static char *argv[] = {name, nullptr};
|
||||
// Leaked on purpose: a function-local static QApplication is destroyed before Qt's
|
||||
// own library statics at exit, and the network module's destructors then run
|
||||
// against a torn-down QObject layer (SEGV under AddressSanitizer).
|
||||
static QApplication *app = new QApplication(argc, argv);
|
||||
Q_UNUSED(app);
|
||||
}
|
||||
|
||||
QString resourcesDir()
|
||||
{
|
||||
return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources"));
|
||||
}
|
||||
|
||||
/** @brief Reads a sentinel SVG from the shipped icon set. */
|
||||
QByteArray readIconSvg(const char *name)
|
||||
{
|
||||
QFile file(QDir(resourcesDir())
|
||||
.absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg")));
|
||||
return file.open(QFile::ReadOnly) ? file.readAll() : QByteArray();
|
||||
}
|
||||
|
||||
/** @brief Renders a pixmap at device size and tags it with the ratio. */
|
||||
template <typename Render> QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render)
|
||||
{
|
||||
QPixmap pixmap = render(logicalSize * dpr);
|
||||
pixmap.setDevicePixelRatio(dpr);
|
||||
return pixmap;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* @brief Writes one badge/sticker sheet per shipped palette theme.
|
||||
*
|
||||
* The treatments are only useful if they read on every palette, so each sheet
|
||||
* is rendered from the palette the theme actually ships: both semantic
|
||||
* families, three glyphs each, as line glyphs, stickers and badges, at the two
|
||||
* sizes the UI uses, on the window and button surfaces the contrast is measured
|
||||
* against. A disabled row closes each family: every treatment falls back to the
|
||||
* line glyph there, so an unavailable control still reads as unavailable.
|
||||
*/
|
||||
TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme)
|
||||
{
|
||||
const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR");
|
||||
if (outDir.isEmpty()) {
|
||||
GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs";
|
||||
}
|
||||
ensureGuiApplication();
|
||||
ASSERT_TRUE(QDir().mkpath(outDir));
|
||||
|
||||
constexpr int MARGIN = 24;
|
||||
constexpr int LABEL_WIDTH = 190;
|
||||
constexpr int SLOT = 36;
|
||||
constexpr int TREATMENT_WIDTH = 120;
|
||||
constexpr int ROW_HEIGHT = 56;
|
||||
constexpr int PANEL_TITLE = 26;
|
||||
constexpr int COLUMN_HEADER = 20;
|
||||
constexpr int PANEL_PADDING = 12;
|
||||
constexpr qreal DPR = 2.0;
|
||||
constexpr int SIZES[] = {16, 24};
|
||||
constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"};
|
||||
|
||||
struct Family
|
||||
{
|
||||
const char *name;
|
||||
AppColor::Role role;
|
||||
const char *glyphs[3];
|
||||
};
|
||||
const QList<Family> families = {
|
||||
{"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}},
|
||||
{"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}},
|
||||
};
|
||||
const QStringList themes = {
|
||||
QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"),
|
||||
QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"),
|
||||
};
|
||||
|
||||
const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) {
|
||||
QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1()));
|
||||
QPixmap pixmap(size);
|
||||
pixmap.fill(Qt::transparent);
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
renderer.render(&painter);
|
||||
return pixmap;
|
||||
};
|
||||
|
||||
const int treatmentCount = static_cast<int>(std::size(TREATMENTS));
|
||||
const int panelWidth = LABEL_WIDTH + treatmentCount * TREATMENT_WIDTH + PANEL_PADDING;
|
||||
const int panelHeight =
|
||||
PANEL_TITLE + COLUMN_HEADER + static_cast<int>(families.size()) * 2 * ROW_HEIGHT + PANEL_PADDING;
|
||||
const int logicalWidth = 2 * MARGIN + panelWidth;
|
||||
const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN;
|
||||
|
||||
for (const QString &theme : themes) {
|
||||
for (const bool dark : {false, true}) {
|
||||
const QString file =
|
||||
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR))
|
||||
.absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml")
|
||||
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||||
const PaletteConfig config = PaletteConfig::fromFile(file);
|
||||
ASSERT_TRUE(config.hasPalette()) << theme.toStdString();
|
||||
|
||||
const QPalette palette = config.apply(QPalette());
|
||||
const QColor window = palette.color(QPalette::Active, QPalette::Window);
|
||||
const QColor button = palette.color(QPalette::Active, QPalette::Button);
|
||||
const QColor text = palette.color(QPalette::Active, QPalette::WindowText);
|
||||
const QColor disabled = palette.color(QPalette::Disabled, QPalette::WindowText);
|
||||
|
||||
QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)),
|
||||
QImage::Format_ARGB32_Premultiplied);
|
||||
image.fill(window);
|
||||
QPainter painter(&image);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
painter.scale(DPR, DPR);
|
||||
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold));
|
||||
painter.drawText(QPointF(MARGIN, MARGIN),
|
||||
QStringLiteral("%1 — %2 scheme, treatments across glyphs")
|
||||
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||||
|
||||
struct Surface
|
||||
{
|
||||
const char *name;
|
||||
QColor background;
|
||||
};
|
||||
const QList<Surface> surfaces = {
|
||||
{"Window surface", window},
|
||||
{"Button surface", button},
|
||||
};
|
||||
|
||||
qreal panelTop = MARGIN + 30;
|
||||
for (const Surface &surface : surfaces) {
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(surface.background);
|
||||
painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8);
|
||||
|
||||
const qreal contentLeft = MARGIN + PANEL_PADDING;
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold));
|
||||
painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name));
|
||||
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 8));
|
||||
for (int treatment = 0; treatment < treatmentCount; ++treatment) {
|
||||
const qreal x = contentLeft + LABEL_WIDTH + static_cast<qreal>(treatment) * TREATMENT_WIDTH;
|
||||
painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14),
|
||||
QString::fromLatin1(TREATMENTS[treatment]) + QStringLiteral(" 16 / 24"));
|
||||
}
|
||||
|
||||
qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER;
|
||||
for (const Family &family : families) {
|
||||
const QColor raw = config.appColors.value(family.role, defaultAppColor(family.role, dark));
|
||||
ASSERT_TRUE(raw.isValid()) << theme.toStdString() << " " << family.name;
|
||||
const QColor tone = ThemeIconFill::saturated(raw);
|
||||
|
||||
for (int state = 0; state < 2; ++state) {
|
||||
const bool isDisabled = state == 1;
|
||||
const QColor ink = isDisabled ? disabled : tone;
|
||||
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold));
|
||||
painter.drawText(
|
||||
QPointF(contentLeft, y + 14),
|
||||
QStringLiteral("%1%2").arg(QString::fromLatin1(family.name),
|
||||
isDisabled ? QStringLiteral(" / disabled") : QString()));
|
||||
painter.setFont(QFont(QStringLiteral("Mono"), 7));
|
||||
painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb));
|
||||
|
||||
for (int treatment = 0; treatment < treatmentCount; ++treatment) {
|
||||
const qreal cellX =
|
||||
contentLeft + LABEL_WIDTH + static_cast<qreal>(treatment) * TREATMENT_WIDTH;
|
||||
for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) {
|
||||
const int side = SIZES[sizeIndex];
|
||||
const QSize size(side, side);
|
||||
const qreal rowTop = y + (sizeIndex == 0 ? 4 : 28);
|
||||
for (int glyphIndex = 0; glyphIndex < 3; ++glyphIndex) {
|
||||
const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]);
|
||||
ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex];
|
||||
const QPixmap pixmap = renderAtDpr(size, DPR, [&](const QSize &pixelSize) {
|
||||
if (isDisabled || treatment == 0) {
|
||||
return renderLine(svg, ink, pixelSize);
|
||||
}
|
||||
if (treatment == 1) {
|
||||
return ThemeIconSticker::renderSticker(svg, ink, pixelSize);
|
||||
}
|
||||
return ThemeIconBadge::renderBadge(svg, ink, pixelSize);
|
||||
});
|
||||
const qreal x =
|
||||
cellX + 6 + static_cast<qreal>(glyphIndex) * SLOT + (SLOT - side) / 2.0;
|
||||
painter.drawPixmap(QPointF(x, rowTop + (SLOT - side) / 2.0), pixmap);
|
||||
}
|
||||
}
|
||||
}
|
||||
y += ROW_HEIGHT;
|
||||
}
|
||||
}
|
||||
panelTop += panelHeight + 24;
|
||||
}
|
||||
|
||||
painter.end();
|
||||
const QString path = QDir(outDir).absoluteFilePath(
|
||||
QStringLiteral("treatment-preview-%1-%2.png")
|
||||
.arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||||
ASSERT_TRUE(image.save(path)) << path.toStdString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,30 @@
|
|||
#include "interface/theme_icon.h"
|
||||
#include "interface/theme_icon_badge.h"
|
||||
#include "interface/theme_icon_fill.h"
|
||||
#include "interface/theme_icon_sticker.h"
|
||||
#include "interface/theme_manager.h"
|
||||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QByteArray>
|
||||
#include <QColor>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFont>
|
||||
#include <QGuiApplication>
|
||||
#include <QImage>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPointF>
|
||||
#include <QSize>
|
||||
#include <QStringList>
|
||||
#include <QStringLiteral>
|
||||
#include <QSvgRenderer>
|
||||
#include <QTemporaryDir>
|
||||
#include <gtest/gtest.h>
|
||||
#include <iterator>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
|
@ -40,6 +51,14 @@ QString resourcesDir()
|
|||
return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources"));
|
||||
}
|
||||
|
||||
/** @brief Reads a sentinel SVG from the shipped icon set. */
|
||||
QByteArray readIconSvg(const char *name)
|
||||
{
|
||||
QFile file(QDir(resourcesDir())
|
||||
.absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg")));
|
||||
return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
|
||||
}
|
||||
|
||||
int countOpaquePixels(const QPixmap &pixmap, const QColor &expected)
|
||||
{
|
||||
const QImage image = pixmap.toImage();
|
||||
|
|
@ -56,6 +75,23 @@ int countOpaquePixels(const QPixmap &pixmap, const QColor &expected)
|
|||
return count;
|
||||
}
|
||||
|
||||
/** @brief Opaque pixels that are not the given rim color, i.e. the gradient fill. */
|
||||
int countOpaquePixelsOtherThan(const QPixmap &pixmap, const QColor &excluded)
|
||||
{
|
||||
const QImage image = pixmap.toImage();
|
||||
int count = 0;
|
||||
for (int y = 0; y < image.height(); ++y) {
|
||||
for (int x = 0; x < image.width(); ++x) {
|
||||
const QColor pixel = image.pixelColor(x, y);
|
||||
if (pixel.alpha() > 200 && (pixel.red() != excluded.red() || pixel.green() != excluded.green() ||
|
||||
pixel.blue() != excluded.blue())) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
QByteArray svgWith(const QByteArray &colorToken)
|
||||
{
|
||||
return QByteArrayLiteral(R"(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill=")") +
|
||||
|
|
@ -187,3 +223,877 @@ TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved)
|
|||
ASSERT_FALSE(pixmap.isNull());
|
||||
EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#123456"))), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A semantic tint must paint its theme color, not the selection accent.
|
||||
*
|
||||
* A theme written before Positive/Negative existed has no stored value, so this
|
||||
* also covers the fallback that used to return the highlight color.
|
||||
*/
|
||||
TEST_F(ThemeIconRenderTest, AppColorTintUsesSemanticColorNotTheAccent)
|
||||
{
|
||||
QPalette palette = qApp->palette();
|
||||
const QColor accent(QStringLiteral("#ff00ff"));
|
||||
palette.setColor(QPalette::Active, QPalette::Highlight, accent);
|
||||
qApp->setPalette(palette);
|
||||
|
||||
const QColor positive = themeManager->appColor(AppColor::Positive);
|
||||
const QColor negative = themeManager->appColor(AppColor::Negative);
|
||||
ASSERT_TRUE(positive.isValid());
|
||||
ASSERT_TRUE(negative.isValid());
|
||||
EXPECT_NE(positive, accent);
|
||||
EXPECT_NE(negative, accent);
|
||||
|
||||
const QPixmap positivePixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg"), AppColor::Positive);
|
||||
ASSERT_FALSE(positivePixmap.isNull());
|
||||
EXPECT_GT(countOpaquePixels(positivePixmap, positive), 0);
|
||||
EXPECT_EQ(countOpaquePixels(positivePixmap, accent), 0);
|
||||
|
||||
const QPixmap negativePixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg"), AppColor::Negative);
|
||||
ASSERT_FALSE(negativePixmap.isNull());
|
||||
EXPECT_GT(countOpaquePixels(negativePixmap, negative), 0);
|
||||
EXPECT_EQ(countOpaquePixels(negativePixmap, accent), 0);
|
||||
}
|
||||
|
||||
/** @brief A disabled control must not keep signalling its semantic color. */
|
||||
TEST_F(ThemeIconRenderTest, DisabledAppColorUsesDisabledForeground)
|
||||
{
|
||||
QPalette palette = qApp->palette();
|
||||
const QColor disabledText(QStringLiteral("#fedcba"));
|
||||
palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText);
|
||||
qApp->setPalette(palette);
|
||||
|
||||
const IconTint tint(AppColor::Negative);
|
||||
EXPECT_EQ(tint.resolve(QIcon::Disabled), disabledText);
|
||||
EXPECT_NE(tint.resolve(QIcon::Normal), disabledText);
|
||||
}
|
||||
|
||||
/** @brief Palette tints keep the WindowText -> HighlightedText selected swap. */
|
||||
TEST_F(ThemeIconRenderTest, SelectedPaletteTintUsesHighlightedText)
|
||||
{
|
||||
QPalette palette = qApp->palette();
|
||||
const QColor activeText(QStringLiteral("#111111"));
|
||||
const QColor selectedText(QStringLiteral("#222222"));
|
||||
palette.setColor(QPalette::Active, QPalette::WindowText, activeText);
|
||||
palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText);
|
||||
qApp->setPalette(palette);
|
||||
|
||||
const IconTint tint(QPalette::WindowText);
|
||||
EXPECT_EQ(tint.resolve(QIcon::Normal), activeText);
|
||||
EXPECT_EQ(tint.resolve(QIcon::Selected), selectedText);
|
||||
}
|
||||
|
||||
/** @brief A selected row falls back to HighlightedText when the tint vanishes. */
|
||||
TEST_F(ThemeIconRenderTest, SelectedAppColorFallsBackWhenItMatchesTheHighlight)
|
||||
{
|
||||
const QColor semantic = themeManager->appColor(AppColor::Positive);
|
||||
const QColor selectedText(QStringLiteral("#fedcba"));
|
||||
QPalette palette = qApp->palette();
|
||||
palette.setColor(QPalette::Active, QPalette::Highlight, semantic);
|
||||
palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText);
|
||||
qApp->setPalette(palette);
|
||||
|
||||
EXPECT_EQ(IconTint(AppColor::Positive).resolve(QIcon::Selected), selectedText);
|
||||
}
|
||||
|
||||
/** @brief A selected row keeps a semantic tint that still contrasts enough. */
|
||||
TEST_F(ThemeIconRenderTest, SelectedAppColorKeepsAContrastingTint)
|
||||
{
|
||||
QPalette palette = qApp->palette();
|
||||
palette.setColor(QPalette::Active, QPalette::Highlight, QColor(Qt::black));
|
||||
palette.setColor(QPalette::Active, QPalette::HighlightedText, QColor(Qt::white));
|
||||
qApp->setPalette(palette);
|
||||
|
||||
EXPECT_EQ(IconTint(AppColor::Positive).resolve(QIcon::Selected), themeManager->appColor(AppColor::Positive));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Line glyphs share the filled treatments' saturation floor.
|
||||
*
|
||||
* The dark-scheme semantic tones are the visible case, but `AccentSoft` is below
|
||||
* the floor in every shipped light palette, so the resolve path is exercised
|
||||
* without swapping the theme. A line glyph next to a badge or a sticker has to
|
||||
* carry the same tone; reverting the floor here would leave it pale beside them.
|
||||
*/
|
||||
TEST_F(ThemeIconRenderTest, LineTintAppliesTheFillSaturationFloor)
|
||||
{
|
||||
const QColor raw = themeManager->appColor(AppColor::AccentSoft);
|
||||
ASSERT_TRUE(raw.isValid());
|
||||
ASSERT_LT(raw.hsvSaturation(), 170);
|
||||
|
||||
const QColor saturated = ThemeIconFill::saturated(raw);
|
||||
EXPECT_NE(saturated, raw);
|
||||
EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Normal), saturated);
|
||||
|
||||
const QIcon icon = loadThemedIcon(QStringLiteral("icons/arrow_right"), AppColor::AccentSoft);
|
||||
const QPixmap pixmap = icon.pixmap(QSize(24, 24));
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
EXPECT_GT(countOpaquePixels(pixmap, saturated), 0);
|
||||
EXPECT_EQ(countOpaquePixels(pixmap, raw), 0);
|
||||
}
|
||||
|
||||
/** @brief A badge is a disc with a gradient fill, a dark ring and a contrast glyph. */
|
||||
TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing)
|
||||
{
|
||||
const QColor tone = themeManager->appColor(AppColor::Positive);
|
||||
ASSERT_TRUE(tone.isValid());
|
||||
|
||||
const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge);
|
||||
const QPixmap pixmap = icon.pixmap(QSize(64, 64));
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
EXPECT_EQ(pixmap.size(), QSize(64, 64));
|
||||
|
||||
const QImage image = pixmap.toImage();
|
||||
// The disc fills the circle; the square corners stay transparent.
|
||||
EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0);
|
||||
EXPECT_GT(image.pixelColor(32, 32).alpha(), 200);
|
||||
|
||||
// The ring is the saturated tone darkened, and it must actually be painted.
|
||||
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0);
|
||||
|
||||
// The fill runs from a lighter top to a darker bottom. Sample left of the
|
||||
// check, which now reaches almost to the disc edge.
|
||||
const int fillX = 10;
|
||||
const QColor top = image.pixelColor(fillX, 16);
|
||||
const QColor bottom = image.pixelColor(fillX, 48);
|
||||
ASSERT_GT(top.alpha(), 200);
|
||||
ASSERT_GT(bottom.alpha(), 200);
|
||||
EXPECT_GT(top.lightness(), bottom.lightness());
|
||||
|
||||
// The glyph uses the contrast color chosen for this tone.
|
||||
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The dark-scheme disc carries the same saturated fill as the sticker.
|
||||
*
|
||||
* The dark tones are tuned for text and read as pale mint or salmon once they
|
||||
* fill a disc, so the badge saturates its fill. The dark scheme keeps the
|
||||
* darkened ink the badge had before the fill moved to the sticker ramp.
|
||||
*/
|
||||
TEST_F(ThemeIconRenderTest, BadgeDarkSchemeDiscUsesTheSaturatedFill)
|
||||
{
|
||||
const QColor tone = defaultAppColor(AppColor::Positive, true);
|
||||
ASSERT_TRUE(tone.isValid());
|
||||
ASSERT_GE(tone.value(), 200);
|
||||
|
||||
const QPixmap pixmap = ThemeIconBadge::renderBadge(readIconSvg("ready_start"), tone, QSize(64, 64));
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
const QImage image = pixmap.toImage();
|
||||
|
||||
// The disc neither keeps the pale raw tone nor the old unsaturating bottom.
|
||||
EXPECT_EQ(countOpaquePixels(pixmap, tone), 0);
|
||||
EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(104)), 0);
|
||||
const QColor top = image.pixelColor(10, 16);
|
||||
const QColor bottom = image.pixelColor(10, 48);
|
||||
ASSERT_GT(top.alpha(), 200);
|
||||
ASSERT_GT(bottom.alpha(), 200);
|
||||
// Antialiasing costs a point or two of saturation at the sample points.
|
||||
EXPECT_GE(top.hsvSaturation(), 160);
|
||||
EXPECT_GE(bottom.hsvSaturation(), 160);
|
||||
EXPECT_GT(top.lightness(), bottom.lightness());
|
||||
|
||||
// The dark scheme keeps the darkened ink, and it is actually painted.
|
||||
const QColor ink = ThemeIconBadge::glyphColor(tone);
|
||||
ASSERT_NE(ink, QColor(Qt::white));
|
||||
EXPECT_GT(countOpaquePixels(pixmap, ink), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The badge ink follows the scheme.
|
||||
*
|
||||
* Light-scheme tones keep the white glyph of the old art; dark-scheme tones
|
||||
* keep the darkened ink, because the shared saturated disc is light enough that
|
||||
* white would sit near 2.7:1 at its top. Pinning both cases stops the glyph from
|
||||
* silently flipping back to white when the fill or the palette changes.
|
||||
*/
|
||||
TEST_F(ThemeIconRenderTest, BadgeInkFollowsTheScheme)
|
||||
{
|
||||
const struct
|
||||
{
|
||||
AppColor::Role role;
|
||||
bool dark;
|
||||
} cases[] = {{AppColor::Positive, false},
|
||||
{AppColor::Negative, false},
|
||||
{AppColor::Positive, true},
|
||||
{AppColor::Negative, true}};
|
||||
|
||||
for (const auto &testCase : cases) {
|
||||
const QColor tone = defaultAppColor(testCase.role, testCase.dark);
|
||||
ASSERT_TRUE(tone.isValid());
|
||||
const QColor ink = ThemeIconBadge::glyphColor(tone);
|
||||
if (testCase.dark) {
|
||||
EXPECT_NE(ink, QColor(Qt::white));
|
||||
EXPECT_LT(ink.value(), 100);
|
||||
} else {
|
||||
EXPECT_EQ(ink, QColor(Qt::white));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief The badge is rendered at the device-pixel size, not upscaled later. */
|
||||
TEST_F(ThemeIconRenderTest, BadgeHonoursDevicePixelRatio)
|
||||
{
|
||||
const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge);
|
||||
const QPixmap hidpi = icon.pixmap(QSize(32, 32), 2.0);
|
||||
|
||||
ASSERT_FALSE(hidpi.isNull());
|
||||
EXPECT_EQ(hidpi.size(), QSize(64, 64));
|
||||
EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0);
|
||||
EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(32, 32));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The player list is the one place the badge has to work, at 15px.
|
||||
*
|
||||
* A 20x15 icon box yields a 15px canvas. The art this replaces kept its check
|
||||
* legible there, so the badge must still paint solid glyph ink rather than an
|
||||
* antialiased smudge.
|
||||
*/
|
||||
TEST_F(ThemeIconRenderTest, BadgeStaysLegibleAtPlayerListSize)
|
||||
{
|
||||
const QColor tone = themeManager->appColor(AppColor::Positive);
|
||||
const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge);
|
||||
const QPixmap pixmap = icon.pixmap(QSize(20, 15));
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
EXPECT_EQ(pixmap.deviceIndependentSize(), QSizeF(15, 15));
|
||||
|
||||
// The disc survives: the corners stay transparent and the center is solid.
|
||||
const QImage image = pixmap.toImage();
|
||||
EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0);
|
||||
EXPECT_GT(image.pixelColor(7, 7).alpha(), 200);
|
||||
// A 1px ring on a 15px canvas antialiases into the disc and has no exact
|
||||
// color, so the glyph is what has to stay legible.
|
||||
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 8);
|
||||
}
|
||||
|
||||
/** @brief The pixmap API's badge path agrees with the engine's. */
|
||||
TEST_F(ThemeIconRenderTest, SentinelSvgBadgeMatchesTheEngine)
|
||||
{
|
||||
const QColor tone = themeManager->appColor(AppColor::Positive);
|
||||
const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/ready_start.svg"), AppColor::Positive,
|
||||
QSize(48, 48), IconStyle::Badge);
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
EXPECT_EQ(pixmap.size(), QSize(48, 48));
|
||||
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0);
|
||||
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Hides Qt's plugin directories while it lives, then restores them.
|
||||
*
|
||||
* Only the image-format plugins matter here: the platform plugin the fixture
|
||||
* needs is already loaded by the time a test runs.
|
||||
*/
|
||||
class ScopedPluginPathHiding
|
||||
{
|
||||
public:
|
||||
ScopedPluginPathHiding() : saved(QCoreApplication::libraryPaths())
|
||||
{
|
||||
QCoreApplication::setLibraryPaths({});
|
||||
}
|
||||
~ScopedPluginPathHiding()
|
||||
{
|
||||
QCoreApplication::setLibraryPaths(saved);
|
||||
}
|
||||
ScopedPluginPathHiding(const ScopedPluginPathHiding &) = delete;
|
||||
ScopedPluginPathHiding &operator=(const ScopedPluginPathHiding &) = delete;
|
||||
|
||||
private:
|
||||
QStringList saved;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Authored SVG art must not depend on Qt's SVG image-format plugin.
|
||||
*
|
||||
* Loading an `.svg` through QPixmap needs the separate `qsvg` image plugin.
|
||||
* Distributions that build Qt with the plugin split out (Debian, and therefore
|
||||
* CI) ship it in its own package, and a `--no-install-recommends` install does
|
||||
* not have it, so authored theme art rendered blank there while working on a
|
||||
* developer machine whose Qt bundles the format. Hiding the plugin directories
|
||||
* reproduces that install.
|
||||
*/
|
||||
TEST_F(ThemeIconRenderTest, AuthoredArtDoesNotNeedTheSvgImagePlugin)
|
||||
{
|
||||
QTemporaryDir themeDir;
|
||||
ASSERT_TRUE(themeDir.isValid());
|
||||
QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg")));
|
||||
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
|
||||
file.write(svgWith("#336699"));
|
||||
file.close();
|
||||
QDir::addSearchPath(QStringLiteral("theme"), themeDir.path());
|
||||
|
||||
// The offscreen platform plugin is already loaded by the fixture, so an
|
||||
// empty library path only removes the image-format plugins.
|
||||
const ScopedPluginPathHiding hidePlugins;
|
||||
|
||||
const QIcon icon = loadThemedIcon(QStringLiteral("plain"));
|
||||
const QPixmap pixmap = icon.pixmap(QSize(32, 32));
|
||||
|
||||
ASSERT_FALSE(pixmap.isNull()) << "authored SVG art went through the QPixmap loader";
|
||||
EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0);
|
||||
}
|
||||
|
||||
/** @brief Badge never paints authored art: the asset keeps its own colors. */
|
||||
TEST_F(ThemeIconRenderTest, BadgeOnAuthoredArtUsesTheAssetPath)
|
||||
{
|
||||
QTemporaryDir themeDir;
|
||||
ASSERT_TRUE(themeDir.isValid());
|
||||
QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg")));
|
||||
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
|
||||
file.write(svgWith("#336699"));
|
||||
file.close();
|
||||
QDir::addSearchPath(QStringLiteral("theme"), themeDir.path());
|
||||
|
||||
const QColor tone = themeManager->appColor(AppColor::Positive);
|
||||
const QIcon icon = loadThemedIcon(QStringLiteral("plain"), AppColor::Positive, IconStyle::Badge);
|
||||
const QPixmap pixmap = icon.pixmap(QSize(32, 32));
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
|
||||
// The authored color survives; no disc tone and no ring are painted.
|
||||
EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0);
|
||||
EXPECT_EQ(countOpaquePixels(pixmap, tone), 0);
|
||||
EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0);
|
||||
}
|
||||
|
||||
/** @brief Disabled keeps the shape language but drops back to the line glyph. */
|
||||
TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph)
|
||||
{
|
||||
QPalette palette = qApp->palette();
|
||||
const QColor disabledText(QStringLiteral("#fedcba"));
|
||||
palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText);
|
||||
qApp->setPalette(palette);
|
||||
|
||||
const QColor tone = themeManager->appColor(AppColor::Positive);
|
||||
const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge);
|
||||
const QPixmap pixmap = icon.pixmap(QSize(64, 64), QIcon::Disabled);
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
|
||||
// A filled disc at full mass would not read as unavailable, so Disabled
|
||||
// drops the badge: no ring, no disc, just the disabled line glyph.
|
||||
EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0);
|
||||
EXPECT_EQ(countOpaquePixels(pixmap, disabledText.darker(300)), 0);
|
||||
EXPECT_EQ(pixmap.toImage().pixelColor(0, 0).alpha(), 0);
|
||||
EXPECT_GT(countOpaquePixels(pixmap, disabledText), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sticker fills the glyph with the tone and rims it with a dark outline.
|
||||
*
|
||||
* The old add/remove art was a filled shape with a black outline and no disc, so
|
||||
* the sticker must show a solid tone interior, a dark rim, and transparent
|
||||
* corners.
|
||||
*/
|
||||
TEST_F(ThemeIconRenderTest, StickerPaintsToneFillWithDarkOutline)
|
||||
{
|
||||
const QColor tone = themeManager->appColor(AppColor::Positive);
|
||||
const QIcon icon = loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker);
|
||||
const QPixmap pixmap = icon.pixmap(QSize(64, 64));
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
EXPECT_EQ(pixmap.size(), QSize(64, 64));
|
||||
|
||||
const QImage image = pixmap.toImage();
|
||||
// No disc: the corners stay transparent, unlike the badge.
|
||||
EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0);
|
||||
EXPECT_EQ(image.pixelColor(63, 63).alpha(), 0);
|
||||
|
||||
// The glyph is filled: the vertical arm carries a gradient, lighter at the
|
||||
// top and darker at the bottom, with the rim painted in the darkened tone.
|
||||
const QColor top = image.pixelColor(32, 12);
|
||||
const QColor bottom = image.pixelColor(32, 52);
|
||||
ASSERT_GT(top.alpha(), 200);
|
||||
ASSERT_GT(bottom.alpha(), 200);
|
||||
EXPECT_GT(top.lightness(), bottom.lightness());
|
||||
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The dark-scheme fill is the branch the treatment exists for.
|
||||
*
|
||||
* That tone is light and deliberately desaturated, so a plain gradient paints a
|
||||
* pale mint shape. The fill has to raise its saturation and pull its value down
|
||||
* instead of up.
|
||||
*/
|
||||
TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillIsSaturatedAndDarkerThanTheTone)
|
||||
{
|
||||
const QColor tone = defaultAppColor(AppColor::Positive, true);
|
||||
ASSERT_TRUE(tone.isValid());
|
||||
ASSERT_GE(tone.value(), 200);
|
||||
|
||||
const QPixmap pixmap = ThemeIconSticker::renderSticker(readIconSvg("increment"), tone, QSize(64, 64));
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
const QImage image = pixmap.toImage();
|
||||
|
||||
const QColor top = image.pixelColor(32, 14);
|
||||
const QColor bottom = image.pixelColor(32, 50);
|
||||
ASSERT_GT(top.alpha(), 200);
|
||||
ASSERT_GT(bottom.alpha(), 200);
|
||||
EXPECT_LT(top.value(), tone.value());
|
||||
EXPECT_LT(bottom.value(), tone.value());
|
||||
EXPECT_GT(top.lightness(), bottom.lightness());
|
||||
EXPECT_GE(top.hsvSaturation(), 170);
|
||||
}
|
||||
|
||||
/** @brief The sticker is rendered at the device-pixel size, not upscaled later. */
|
||||
TEST_F(ThemeIconRenderTest, StickerHonoursDevicePixelRatio)
|
||||
{
|
||||
const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker);
|
||||
const QPixmap hidpi = icon.pixmap(QSize(32, 32), 2.0);
|
||||
|
||||
ASSERT_FALSE(hidpi.isNull());
|
||||
EXPECT_EQ(hidpi.size(), QSize(64, 64));
|
||||
EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0);
|
||||
EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(32, 32));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A sticker has to survive the 16px toolbar slot.
|
||||
*
|
||||
* A 15/16px canvas leaves a three-pixel band. The tone interior must still be
|
||||
* solid ink rather than an antialiased smudge, and the icon must not turn into a
|
||||
* disc.
|
||||
*/
|
||||
TEST_F(ThemeIconRenderTest, StickerStaysLegibleAtToolbarSize)
|
||||
{
|
||||
const QColor tone = themeManager->appColor(AppColor::Negative);
|
||||
const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker);
|
||||
const QPixmap pixmap = icon.pixmap(QSize(16, 16));
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
EXPECT_EQ(pixmap.size(), QSize(16, 16));
|
||||
|
||||
const QImage image = pixmap.toImage();
|
||||
EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0);
|
||||
EXPECT_GT(countOpaquePixelsOtherThan(pixmap, ThemeIconSticker::outlineColor(tone)), 8);
|
||||
}
|
||||
|
||||
/** @brief The pixmap API's sticker path agrees with the engine's. */
|
||||
TEST_F(ThemeIconRenderTest, SentinelSvgStickerMatchesTheEngine)
|
||||
{
|
||||
const QColor tone = themeManager->appColor(AppColor::Negative);
|
||||
const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/remove_row.svg"), AppColor::Negative,
|
||||
QSize(48, 48), IconStyle::Sticker);
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
EXPECT_EQ(pixmap.size(), QSize(48, 48));
|
||||
EXPECT_GT(countOpaquePixelsOtherThan(pixmap, ThemeIconSticker::outlineColor(tone)), 0);
|
||||
EXPECT_GT(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0);
|
||||
}
|
||||
|
||||
/** @brief Sticker never paints authored art: the asset keeps its own colors. */
|
||||
TEST_F(ThemeIconRenderTest, StickerOnAuthoredArtUsesTheAssetPath)
|
||||
{
|
||||
QTemporaryDir themeDir;
|
||||
ASSERT_TRUE(themeDir.isValid());
|
||||
QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg")));
|
||||
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
|
||||
file.write(svgWith("#336699"));
|
||||
file.close();
|
||||
QDir::addSearchPath(QStringLiteral("theme"), themeDir.path());
|
||||
|
||||
const QColor tone = themeManager->appColor(AppColor::Negative);
|
||||
const QIcon icon = loadThemedIcon(QStringLiteral("plain"), AppColor::Negative, IconStyle::Sticker);
|
||||
const QPixmap pixmap = icon.pixmap(QSize(32, 32));
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
|
||||
EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0);
|
||||
EXPECT_EQ(countOpaquePixels(pixmap, tone), 0);
|
||||
EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0);
|
||||
}
|
||||
|
||||
/** @brief Disabled keeps the shape language but drops back to the line glyph. */
|
||||
TEST_F(ThemeIconRenderTest, DisabledStickerFallsBackToLineGlyph)
|
||||
{
|
||||
QPalette palette = qApp->palette();
|
||||
const QColor disabledText(QStringLiteral("#fedcba"));
|
||||
palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText);
|
||||
qApp->setPalette(palette);
|
||||
|
||||
const QColor tone = themeManager->appColor(AppColor::Negative);
|
||||
const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker);
|
||||
const QPixmap pixmap = icon.pixmap(QSize(64, 64), QIcon::Disabled);
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
|
||||
// No rim in the sticker tone: Disabled drops the treatment and paints the
|
||||
// thin disabled line glyph instead.
|
||||
EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0);
|
||||
EXPECT_GT(countOpaquePixels(pixmap, disabledText), 0);
|
||||
}
|
||||
|
||||
/** @brief Line stays the default: no disc, no ring, just the recolored glyph. */
|
||||
TEST_F(ThemeIconRenderTest, LineStyleStillRendersGlyphAlone)
|
||||
{
|
||||
const QColor tone = themeManager->appColor(AppColor::Positive);
|
||||
const QPixmap pixmap =
|
||||
loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive).pixmap(QSize(64, 64));
|
||||
ASSERT_FALSE(pixmap.isNull());
|
||||
|
||||
EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0);
|
||||
EXPECT_GT(countOpaquePixels(pixmap, tone), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Writes the semantic-tint preview sheet when asked; skipped otherwise.
|
||||
*
|
||||
* #7775 put the semantic tone back on line glyphs, before any filled treatment
|
||||
* existed. The sheet renders the affected glyphs with the raw per-scheme
|
||||
* `AppColor` value, which is what that PR paints, at the sizes the UI uses.
|
||||
*/
|
||||
TEST(IconTintPreview, WritesPngsWhenRequested)
|
||||
{
|
||||
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||||
ensureGuiApplication();
|
||||
|
||||
const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR");
|
||||
if (outDir.isEmpty()) {
|
||||
GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs";
|
||||
}
|
||||
ASSERT_TRUE(QDir().mkpath(outDir));
|
||||
|
||||
struct Glyph
|
||||
{
|
||||
const char *name;
|
||||
AppColor::Role role;
|
||||
};
|
||||
constexpr Glyph tinted[] = {
|
||||
{"ready_start", AppColor::Positive}, {"not_ready_start", AppColor::Negative}, {"increment", AppColor::Positive},
|
||||
{"decrement", AppColor::Negative}, {"remove_row", AppColor::Negative}, {"delete", AppColor::Negative},
|
||||
{"arrow_right", AppColor::Positive},
|
||||
};
|
||||
constexpr int SIZES[] = {15, 16, 24, 48};
|
||||
|
||||
const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) {
|
||||
QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()));
|
||||
QPixmap pixmap(size);
|
||||
pixmap.fill(Qt::transparent);
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
renderer.render(&painter);
|
||||
return pixmap;
|
||||
};
|
||||
|
||||
constexpr int MARGIN = 24;
|
||||
constexpr int LABEL_WIDTH = 130;
|
||||
constexpr int CELL_WIDTH = 120;
|
||||
constexpr int ROW_HEIGHT = 70;
|
||||
constexpr qreal DPR = 2.0;
|
||||
|
||||
const int logicalWidth = MARGIN + LABEL_WIDTH + static_cast<int>(std::size(SIZES)) * CELL_WIDTH + MARGIN;
|
||||
const int logicalHeight = MARGIN + 24 + 34 + static_cast<int>(std::size(tinted)) * ROW_HEIGHT + 40 + MARGIN;
|
||||
const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR));
|
||||
|
||||
for (const bool dark : {false, true}) {
|
||||
const QColor window(dark ? QStringLiteral("#1e1e1e") : QStringLiteral("#ffffff"));
|
||||
const QColor text(dark ? QStringLiteral("#e8e8e8") : QStringLiteral("#1e1e1e"));
|
||||
|
||||
QImage image(imageSize, QImage::Format_ARGB32_Premultiplied);
|
||||
image.fill(window);
|
||||
QPainter painter(&image);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
painter.scale(DPR, DPR);
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 9));
|
||||
|
||||
qreal y = MARGIN + 4;
|
||||
painter.drawText(QPointF(MARGIN, y), dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme"));
|
||||
|
||||
y += 24;
|
||||
painter.drawText(QPointF(MARGIN, y), QStringLiteral("Semantic tints on line glyphs (#7775)"));
|
||||
y += 10;
|
||||
|
||||
for (const Glyph &glyph : tinted) {
|
||||
y += ROW_HEIGHT;
|
||||
const qreal rowTop = y - ROW_HEIGHT;
|
||||
QFile file(QDir(resourcesDir())
|
||||
.absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(glyph.name) +
|
||||
QStringLiteral(".svg")));
|
||||
ASSERT_TRUE(file.open(QIODevice::ReadOnly)) << glyph.name;
|
||||
const QByteArray svg = file.readAll();
|
||||
const QColor tone = defaultAppColor(glyph.role, dark);
|
||||
painter.drawText(QPointF(MARGIN, rowTop + 16), QString::fromLatin1(glyph.name));
|
||||
for (std::size_t i = 0; i < std::size(SIZES); ++i) {
|
||||
const int side = SIZES[i];
|
||||
const QPixmap pixmap = renderLine(svg, tone, QSize(side, side));
|
||||
const qreal x = MARGIN + LABEL_WIDTH + static_cast<qreal>(i) * CELL_WIDTH;
|
||||
// Bottom-align the sizes and put the caption below the 48px row.
|
||||
painter.drawPixmap(QPointF(x, rowTop + 54 - side), pixmap);
|
||||
painter.drawText(QPointF(x, rowTop + 66), QStringLiteral("%1px").arg(side));
|
||||
}
|
||||
}
|
||||
|
||||
y += 20;
|
||||
painter.drawText(QPointF(MARGIN, y),
|
||||
QStringLiteral("Positive: ready and add. Negative: not ready, remove and delete."));
|
||||
|
||||
painter.end();
|
||||
const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("tint-preview-dark.png")
|
||||
: QStringLiteral("tint-preview-light.png"));
|
||||
ASSERT_TRUE(image.save(path)) << path.toStdString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Writes the sticker preview sheet when asked; skipped otherwise.
|
||||
*
|
||||
* The sheet shows each add/remove glyph as line and as sticker at the
|
||||
* sizes the UI uses (15px in the player list and menus, 16px in toolbars, 24 and
|
||||
* 48 for inspection), with the ready/not-ready badge for context. It lives in the
|
||||
* test binary so the colors come from the same place the engine uses.
|
||||
*/
|
||||
TEST(IconStickerPreview, WritesPngsWhenRequested)
|
||||
{
|
||||
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||||
ensureGuiApplication();
|
||||
|
||||
const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR");
|
||||
if (outDir.isEmpty()) {
|
||||
GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs";
|
||||
}
|
||||
ASSERT_TRUE(QDir().mkpath(outDir));
|
||||
|
||||
struct Glyph
|
||||
{
|
||||
const char *name;
|
||||
AppColor::Role role;
|
||||
};
|
||||
constexpr Glyph stickered[] = {
|
||||
{"increment", AppColor::Positive},
|
||||
{"decrement", AppColor::Negative},
|
||||
{"remove_row", AppColor::Negative},
|
||||
{"delete", AppColor::Negative},
|
||||
};
|
||||
constexpr int SIZES[] = {15, 16, 24, 48};
|
||||
constexpr int CONTEXT_SIZE = 32;
|
||||
|
||||
const auto readSvg = [](const char *name) {
|
||||
QFile file(
|
||||
QDir(resourcesDir())
|
||||
.absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg")));
|
||||
return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
|
||||
};
|
||||
const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) {
|
||||
QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()));
|
||||
QPixmap pixmap(size);
|
||||
pixmap.fill(Qt::transparent);
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
renderer.render(&painter);
|
||||
return pixmap;
|
||||
};
|
||||
|
||||
constexpr int MARGIN = 24;
|
||||
constexpr int LABEL_WIDTH = 110;
|
||||
constexpr int CELL_WIDTH = 120;
|
||||
constexpr int ROW_HEIGHT = 58;
|
||||
constexpr int GLYPH_BLOCK_HEIGHT = 22 + 2 * ROW_HEIGHT;
|
||||
constexpr qreal DPR = 2.0;
|
||||
|
||||
const int logicalWidth = MARGIN + LABEL_WIDTH + static_cast<int>(std::size(SIZES)) * CELL_WIDTH + MARGIN;
|
||||
const int logicalHeight = MARGIN + 24 + 24 + static_cast<int>(std::size(stickered)) * GLYPH_BLOCK_HEIGHT + 24 + 24 +
|
||||
CONTEXT_SIZE + 46 + CONTEXT_SIZE + 46 + MARGIN;
|
||||
const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR));
|
||||
|
||||
for (const bool dark : {false, true}) {
|
||||
const QColor window(dark ? QStringLiteral("#1e1e1e") : QStringLiteral("#ffffff"));
|
||||
const QColor text(dark ? QStringLiteral("#e8e8e8") : QStringLiteral("#1e1e1e"));
|
||||
|
||||
QImage image(imageSize, QImage::Format_ARGB32_Premultiplied);
|
||||
image.fill(window);
|
||||
QPainter painter(&image);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
painter.scale(DPR, DPR);
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 9));
|
||||
|
||||
qreal y = MARGIN + 4;
|
||||
painter.drawText(QPointF(MARGIN, y), dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme"));
|
||||
|
||||
y += 24;
|
||||
painter.drawText(QPointF(MARGIN, y), QStringLiteral("Add / remove (line above, sticker below)"));
|
||||
for (const Glyph &glyph : stickered) {
|
||||
y += GLYPH_BLOCK_HEIGHT;
|
||||
const QByteArray svg = readSvg(glyph.name);
|
||||
ASSERT_FALSE(svg.isEmpty()) << glyph.name;
|
||||
// The preview must show the line glyph the engine paints, and the
|
||||
// engine saturates AppColor tones before drawing them.
|
||||
const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark));
|
||||
|
||||
for (int row = 0; row < 2; ++row) {
|
||||
const bool sticker = row == 1;
|
||||
const qreal rowTop = y - GLYPH_BLOCK_HEIGHT + 22 + row * ROW_HEIGHT;
|
||||
painter.drawText(QPointF(MARGIN, rowTop + 16),
|
||||
(sticker ? QStringLiteral("sticker ") : QStringLiteral("line ")) +
|
||||
QString::fromLatin1(glyph.name));
|
||||
for (std::size_t i = 0; i < std::size(SIZES); ++i) {
|
||||
const int side = SIZES[i];
|
||||
const QPixmap pixmap = sticker ? ThemeIconSticker::renderSticker(svg, tone, QSize(side, side))
|
||||
: renderLine(svg, tone, QSize(side, side));
|
||||
const qreal x = MARGIN + LABEL_WIDTH + static_cast<qreal>(i) * CELL_WIDTH;
|
||||
painter.drawPixmap(QPointF(x, rowTop + 36 - side), pixmap);
|
||||
if (row == 1) {
|
||||
painter.drawText(QPointF(x, rowTop + 54), QStringLiteral("%1px").arg(side));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
y += 24;
|
||||
painter.drawText(QPointF(MARGIN, y),
|
||||
QStringLiteral("Ready / not ready keep the badge (%1px)").arg(CONTEXT_SIZE));
|
||||
constexpr Glyph badgeContext[] = {
|
||||
{"ready_start", AppColor::Positive},
|
||||
{"not_ready_start", AppColor::Negative},
|
||||
};
|
||||
for (std::size_t i = 0; i < std::size(badgeContext); ++i) {
|
||||
const QByteArray svg = readSvg(badgeContext[i].name);
|
||||
ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name;
|
||||
const QColor tone = defaultAppColor(badgeContext[i].role, dark);
|
||||
const qreal x = MARGIN + LABEL_WIDTH + static_cast<qreal>(i) * CELL_WIDTH;
|
||||
painter.drawPixmap(QPointF(x, y + 8),
|
||||
ThemeIconBadge::renderBadge(svg, tone, QSize(CONTEXT_SIZE, CONTEXT_SIZE)));
|
||||
painter.drawText(QPointF(x, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(badgeContext[i].name));
|
||||
}
|
||||
|
||||
y += CONTEXT_SIZE + 46;
|
||||
painter.drawText(QPointF(MARGIN, y),
|
||||
QStringLiteral("Line / badge / sticker share one tone (%1px)").arg(CONTEXT_SIZE));
|
||||
constexpr const char *FAMILY_LABELS[] = {"line", "badge", "sticker"};
|
||||
for (std::size_t i = 0; i < std::size(badgeContext); ++i) {
|
||||
const QByteArray svg = readSvg(badgeContext[i].name);
|
||||
ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name;
|
||||
const QColor tone = ThemeIconFill::saturated(defaultAppColor(badgeContext[i].role, dark));
|
||||
const qreal x = MARGIN + LABEL_WIDTH + static_cast<qreal>(i) * CELL_WIDTH * 1.5;
|
||||
for (int j = 0; j < 3; ++j) {
|
||||
const QSize side(CONTEXT_SIZE, CONTEXT_SIZE);
|
||||
QPixmap pixmap;
|
||||
if (j == 0) {
|
||||
pixmap = renderLine(svg, tone, side);
|
||||
} else if (j == 1) {
|
||||
pixmap = ThemeIconBadge::renderBadge(svg, tone, side);
|
||||
} else {
|
||||
pixmap = ThemeIconSticker::renderSticker(svg, tone, side);
|
||||
}
|
||||
const qreal px = x + j * (CONTEXT_SIZE + 16);
|
||||
painter.drawPixmap(QPointF(px, y + 8), pixmap);
|
||||
painter.drawText(QPointF(px, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(FAMILY_LABELS[j]));
|
||||
}
|
||||
}
|
||||
|
||||
painter.end();
|
||||
const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("sticker-preview-dark.png")
|
||||
: QStringLiteral("sticker-preview-light.png"));
|
||||
ASSERT_TRUE(image.save(path)) << path.toStdString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Writes the PR preview sheets when asked; skipped otherwise.
|
||||
*
|
||||
* Two panels per scheme: ready/not-ready as line and badge at the sizes the UI
|
||||
* actually uses (15px in the player list, 16px in toolbars, 24 and 48 for
|
||||
* inspection). It lives in the test binary so the colors come from the same
|
||||
* place the engine uses. The add/remove/delete glyphs and their sticker
|
||||
* treatment have their own sheet (IconStickerPreview).
|
||||
*/
|
||||
TEST(IconBadgePreview, WritesPngsWhenRequested)
|
||||
{
|
||||
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||||
ensureGuiApplication();
|
||||
|
||||
const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR");
|
||||
if (outDir.isEmpty()) {
|
||||
GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs";
|
||||
}
|
||||
ASSERT_TRUE(QDir().mkpath(outDir));
|
||||
|
||||
struct Glyph
|
||||
{
|
||||
const char *name;
|
||||
AppColor::Role role;
|
||||
};
|
||||
constexpr Glyph badged[] = {
|
||||
{"ready_start", AppColor::Positive},
|
||||
{"not_ready_start", AppColor::Negative},
|
||||
};
|
||||
constexpr int SIZES[] = {15, 16, 24, 48};
|
||||
|
||||
const auto readSvg = [](const char *name) {
|
||||
QFile file(
|
||||
QDir(resourcesDir())
|
||||
.absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg")));
|
||||
return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
|
||||
};
|
||||
const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) {
|
||||
QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()));
|
||||
QPixmap pixmap(size);
|
||||
pixmap.fill(Qt::transparent);
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
renderer.render(&painter);
|
||||
return pixmap;
|
||||
};
|
||||
|
||||
constexpr int MARGIN = 24;
|
||||
constexpr int LABEL_WIDTH = 90;
|
||||
constexpr int CELL_WIDTH = 120;
|
||||
constexpr int ROW_HEIGHT = 58;
|
||||
constexpr int GLYPH_BLOCK_HEIGHT = 22 + 2 * ROW_HEIGHT;
|
||||
constexpr qreal DPR = 2.0;
|
||||
|
||||
const int logicalWidth = MARGIN + LABEL_WIDTH + static_cast<int>(std::size(SIZES)) * CELL_WIDTH + MARGIN;
|
||||
const int logicalHeight = MARGIN + 24 + 24 + static_cast<int>(std::size(badged)) * GLYPH_BLOCK_HEIGHT + 30 + MARGIN;
|
||||
const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR));
|
||||
|
||||
for (const bool dark : {false, true}) {
|
||||
const QColor window(dark ? QStringLiteral("#1e1e1e") : QStringLiteral("#ffffff"));
|
||||
const QColor text(dark ? QStringLiteral("#e8e8e8") : QStringLiteral("#1e1e1e"));
|
||||
|
||||
QImage image(imageSize, QImage::Format_ARGB32_Premultiplied);
|
||||
image.fill(window);
|
||||
QPainter painter(&image);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
painter.scale(DPR, DPR);
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 9));
|
||||
|
||||
qreal y = MARGIN + 4;
|
||||
painter.drawText(QPointF(MARGIN, y), dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme"));
|
||||
|
||||
y += 24;
|
||||
painter.drawText(QPointF(MARGIN, y), QStringLiteral("Ready / not ready (line above, badge below)"));
|
||||
for (const Glyph &glyph : badged) {
|
||||
y += GLYPH_BLOCK_HEIGHT;
|
||||
const QByteArray svg = readSvg(glyph.name);
|
||||
ASSERT_FALSE(svg.isEmpty()) << glyph.name;
|
||||
// The preview must show the line glyph the engine paints, and the
|
||||
// engine saturates AppColor tones before drawing them.
|
||||
const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark));
|
||||
|
||||
for (int row = 0; row < 2; ++row) {
|
||||
const bool badge = row == 1;
|
||||
const qreal rowTop = y - GLYPH_BLOCK_HEIGHT + 22 + row * ROW_HEIGHT;
|
||||
painter.drawText(QPointF(MARGIN, rowTop + 16),
|
||||
(badge ? QStringLiteral("badge ") : QStringLiteral("line ")) +
|
||||
QString::fromLatin1(glyph.name));
|
||||
for (std::size_t i = 0; i < std::size(SIZES); ++i) {
|
||||
const int side = SIZES[i];
|
||||
const QPixmap pixmap = badge ? ThemeIconBadge::renderBadge(svg, tone, QSize(side, side))
|
||||
: renderLine(svg, tone, QSize(side, side));
|
||||
const qreal x = MARGIN + LABEL_WIDTH + static_cast<qreal>(i) * CELL_WIDTH;
|
||||
// Align all sizes on a common baseline so the weight
|
||||
// difference between 15px and 48px is visible.
|
||||
painter.drawPixmap(QPointF(x, rowTop + 36 - side), pixmap);
|
||||
if (row == 1) {
|
||||
painter.drawText(QPointF(x, rowTop + 54), QStringLiteral("%1px").arg(side));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
painter.end();
|
||||
const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("badge-preview-dark.png")
|
||||
: QStringLiteral("badge-preview-light.png"));
|
||||
ASSERT_TRUE(image.save(path)) << path.toStdString();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,6 +153,41 @@ TEST_F(ThemeMigrationTest, IsIdempotent)
|
|||
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "style.css").toStdString(), std::string("css"));
|
||||
}
|
||||
|
||||
TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme)
|
||||
{
|
||||
EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1b7f3b")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::Positive, true), QColor(QStringLiteral("#6dd58c")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::Negative, false), QColor(QStringLiteral("#b3261e")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::Negative, true), QColor(QStringLiteral("#ff8a80")));
|
||||
// Accent roles are derived from the palette, so they have no static default.
|
||||
EXPECT_FALSE(defaultAppColor(AppColor::AccentStrong, false).isValid());
|
||||
EXPECT_FALSE(defaultAppColor(AppColor::AccentSoft, true).isValid());
|
||||
}
|
||||
|
||||
TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
|
||||
PaletteConfig config;
|
||||
const QColor positive(QStringLiteral("#1b7f3b"));
|
||||
const QColor negative(QStringLiteral("#b3261e"));
|
||||
config.appColors[AppColor::Positive] = positive;
|
||||
config.appColors[AppColor::Negative] = negative;
|
||||
|
||||
const QString path = QDir(dir.path()).absoluteFilePath(QStringLiteral("palette-light.toml"));
|
||||
QFile file(path);
|
||||
ASSERT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
file.write(config.toToml().toUtf8());
|
||||
file.close();
|
||||
|
||||
const PaletteConfig parsed = PaletteConfig::fromFile(path);
|
||||
ASSERT_TRUE(parsed.appColors.contains(AppColor::Positive));
|
||||
ASSERT_TRUE(parsed.appColors.contains(AppColor::Negative));
|
||||
EXPECT_EQ(parsed.appColors.value(AppColor::Positive), positive);
|
||||
EXPECT_EQ(parsed.appColors.value(AppColor::Negative), negative);
|
||||
}
|
||||
|
||||
// Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and
|
||||
// the fallback then links the bare gtest library without gtest_main.
|
||||
int main(int argc, char **argv)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue