mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-10 03:53:26 -08:00
Compare commits
2 commits
339c969db8
...
eb7e866211
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb7e866211 | ||
|
|
9978be9ab7 |
9 changed files with 404 additions and 0 deletions
|
|
@ -172,6 +172,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/utility/reversed_completer_model.cpp
|
||||
src/interface/pixel_map_generator.cpp
|
||||
src/interface/theme_config.cpp
|
||||
src/interface/theme_icon.cpp
|
||||
src/interface/theme_manager.cpp
|
||||
src/interface/palette_editor/color_button.cpp
|
||||
src/interface/palette_editor/palette_generator.cpp
|
||||
|
|
|
|||
255
cockatrice/src/interface/theme_icon.cpp
Normal file
255
cockatrice/src/interface/theme_icon.cpp
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
#include "theme_icon.h"
|
||||
|
||||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
#include "theme_manager.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QByteArray>
|
||||
#include <QColor>
|
||||
#include <QFile>
|
||||
#include <QHash>
|
||||
#include <QIODevice>
|
||||
#include <QIconEngine>
|
||||
#include <QPaintDevice>
|
||||
#include <QPainter>
|
||||
#include <QPointF>
|
||||
#include <QRect>
|
||||
#include <QRectF>
|
||||
#include <QSize>
|
||||
#include <QString>
|
||||
#include <QStringLiteral>
|
||||
#include <QStyle>
|
||||
#include <QSvgRenderer>
|
||||
#include <QtGlobal>
|
||||
#include <utility>
|
||||
|
||||
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.
|
||||
*/
|
||||
QByteArray readSvg(const QString &resolvedPath)
|
||||
{
|
||||
QFile file(QStringLiteral("theme:") + resolvedPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return {};
|
||||
}
|
||||
return file.readAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Renders SVG bytes, scaled to @p size when one is given.
|
||||
*
|
||||
* An empty size means the asset's intrinsic size; @p devicePixelRatio only
|
||||
* applies in that case so HiDPI callers still get a crisp glyph.
|
||||
*/
|
||||
QPixmap renderSvg(const QByteArray &data, const QSize &size, qreal devicePixelRatio)
|
||||
{
|
||||
QSvgRenderer renderer(data);
|
||||
if (!renderer.isValid()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QSize renderSize = size;
|
||||
if (renderSize.isEmpty()) {
|
||||
renderSize = renderer.defaultSize();
|
||||
renderSize =
|
||||
QSize(qRound(renderSize.width() * devicePixelRatio), qRound(renderSize.height() * devicePixelRatio));
|
||||
}
|
||||
if (!renderSize.isValid() || renderSize.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QPixmap pixmap(renderSize);
|
||||
pixmap.fill(Qt::transparent);
|
||||
QPainter painter(&pixmap);
|
||||
renderer.render(&painter);
|
||||
return pixmap;
|
||||
}
|
||||
|
||||
QString cacheKey(const QString &path, const QByteArray &color, const QSize &size, qreal devicePixelRatio)
|
||||
{
|
||||
return path + QLatin1Char('|') + QString::fromLatin1(color) + QLatin1Char('|') + QString::number(size.width()) +
|
||||
QLatin1Char('x') + QString::number(size.height()) + QLatin1Char('@') + QString::number(devicePixelRatio);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief QIcon engine rendering a `theme:` SVG with `currentColor` resolved on demand.
|
||||
*
|
||||
* Qt's SVG renderer has no ambient color, so the sentinel is substituted from
|
||||
* the current palette on every pixmap request. The render cache is keyed by
|
||||
* resolved path, palette color, pixel size and DPR, so a theme change produces
|
||||
* new keys instead of reusing art built under the previous palette.
|
||||
*/
|
||||
class ThemeSvgIconEngine final : public QIconEngine
|
||||
{
|
||||
public:
|
||||
ThemeSvgIconEngine(QString _prefix, QPalette::ColorRole _role) : prefix(std::move(_prefix)), role(_role)
|
||||
{
|
||||
}
|
||||
|
||||
QIconEngine *clone() const override
|
||||
{
|
||||
return new ThemeSvgIconEngine(prefix, role);
|
||||
}
|
||||
|
||||
void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State) override
|
||||
{
|
||||
const qreal devicePixelRatio = painter->device() ? painter->device()->devicePixelRatioF() : 1.0;
|
||||
const QSize logicalSize = fitToSize(rect.size());
|
||||
if (logicalSize.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
const QSize pixelSize(qRound(logicalSize.width() * devicePixelRatio),
|
||||
qRound(logicalSize.height() * devicePixelRatio));
|
||||
QPixmap pixmap = render(pixelSize, devicePixelRatio, mode);
|
||||
if (!pixmap.isNull()) {
|
||||
pixmap.setDevicePixelRatio(devicePixelRatio);
|
||||
const QSizeF contentSize = pixmap.deviceIndependentSize();
|
||||
const QRectF targetRect(
|
||||
QPointF(rect.center()) - QPointF(contentSize.width() / 2.0, contentSize.height() / 2.0), contentSize);
|
||||
painter->drawPixmap(targetRect, pixmap, QRectF(pixmap.rect()));
|
||||
}
|
||||
}
|
||||
|
||||
QPixmap pixmap(const QSize &size, QIcon::Mode mode, QIcon::State) override
|
||||
{
|
||||
return render(fitToSize(size), 1.0, mode);
|
||||
}
|
||||
|
||||
QPixmap scaledPixmap(const QSize &size, QIcon::Mode mode, QIcon::State, qreal scale) override
|
||||
{
|
||||
const QSize logicalSize = fitToSize(size);
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
|
||||
const QSize pixelSize(qRound(logicalSize.width() * scale), qRound(logicalSize.height() * scale));
|
||||
#else
|
||||
// Qt < 6.8 passes the device-dependent size to the engine.
|
||||
const QSize pixelSize = logicalSize;
|
||||
#endif
|
||||
QPixmap pixmap = render(pixelSize, scale, mode);
|
||||
if (!pixmap.isNull()) {
|
||||
pixmap.setDevicePixelRatio(scale);
|
||||
}
|
||||
return pixmap;
|
||||
}
|
||||
|
||||
QSize actualSize(const QSize &size, QIcon::Mode, QIcon::State) override
|
||||
{
|
||||
return fitToSize(size);
|
||||
}
|
||||
|
||||
private:
|
||||
void ensureResolved() const
|
||||
{
|
||||
const QString themePath = themeManager->getCurrentThemePath();
|
||||
const bool darkMode = themeManager->isDarkModeActive();
|
||||
if (!hasResolvedPath || themePath != resolvedThemePath || darkMode != resolvedDarkMode) {
|
||||
resolvedPath = themeManager->assetPath(prefix);
|
||||
resolvedThemePath = themePath;
|
||||
resolvedDarkMode = darkMode;
|
||||
hasResolvedPath = true;
|
||||
hasIntrinsicSize = false;
|
||||
}
|
||||
}
|
||||
|
||||
QSize intrinsicSize() const
|
||||
{
|
||||
ensureResolved();
|
||||
if (!hasIntrinsicSize) {
|
||||
const QByteArray svg = readSvg(resolvedPath);
|
||||
QSvgRenderer renderer(svg);
|
||||
intrinsicSizeCache = renderer.isValid() ? renderer.defaultSize() : QSize(24, 24);
|
||||
if (intrinsicSizeCache.isEmpty()) {
|
||||
intrinsicSizeCache = QSize(24, 24);
|
||||
}
|
||||
hasIntrinsicSize = true;
|
||||
}
|
||||
return intrinsicSizeCache;
|
||||
}
|
||||
|
||||
QSize fitToSize(const QSize &size) const
|
||||
{
|
||||
if (size.isEmpty()) {
|
||||
return size;
|
||||
}
|
||||
return intrinsicSize().scaled(size, Qt::KeepAspectRatio);
|
||||
}
|
||||
|
||||
QPixmap render(const QSize &pixelSize, qreal devicePixelRatio, QIcon::Mode mode) const
|
||||
{
|
||||
ensureResolved();
|
||||
const QString themePath = resolvedThemePath;
|
||||
const bool darkMode = resolvedDarkMode;
|
||||
|
||||
const QByteArray svg = readSvg(resolvedPath);
|
||||
if (svg.isEmpty() || !QString::fromUtf8(svg).contains(QStringLiteral("currentColor"), Qt::CaseInsensitive)) {
|
||||
// Authored art carries its own colors: use Qt's normal loader so
|
||||
// raster overrides and non-SVG assets keep working.
|
||||
QPixmap pixmap(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);
|
||||
}
|
||||
return pixmap;
|
||||
}
|
||||
|
||||
const QByteArray color = sentinelColor(mode, role);
|
||||
const QString key = cacheKey(resolvedPath, color, pixelSize, devicePixelRatio) + QLatin1Char('|') + themePath +
|
||||
(darkMode ? QLatin1Char('d') : QLatin1Char('l'));
|
||||
const auto cached = cache.constFind(key);
|
||||
if (cached != cache.constEnd()) {
|
||||
return cached.value();
|
||||
}
|
||||
|
||||
QPixmap pixmap = renderSvg(resolveSvgColorSentinel(svg, color), pixelSize, devicePixelRatio);
|
||||
if (cache.size() >= 64) {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, pixmap);
|
||||
return pixmap;
|
||||
}
|
||||
|
||||
QString prefix;
|
||||
QPalette::ColorRole role;
|
||||
mutable QHash<QString, QPixmap> cache;
|
||||
mutable QString resolvedPath;
|
||||
mutable QString resolvedThemePath;
|
||||
mutable QSize intrinsicSizeCache;
|
||||
mutable bool resolvedDarkMode = false;
|
||||
mutable bool hasResolvedPath = false;
|
||||
mutable bool hasIntrinsicSize = false;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role)
|
||||
{
|
||||
return QIcon(new ThemeSvgIconEngine(prefix.toString(), role));
|
||||
}
|
||||
|
||||
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole role, const QSize &size)
|
||||
{
|
||||
if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const QByteArray svg = readSvg(resolvedPath);
|
||||
if (svg.isEmpty() || !QString::fromUtf8(svg).contains(QStringLiteral("currentColor"), Qt::CaseInsensitive)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const QByteArray color = qApp->palette().color(role).name(QColor::HexRgb).toLatin1();
|
||||
return renderSvg(resolveSvgColorSentinel(svg, color), size, 1.0);
|
||||
}
|
||||
50
cockatrice/src/interface/theme_icon.h
Normal file
50
cockatrice/src/interface/theme_icon.h
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* @file theme_icon.h
|
||||
* @ingroup Interface
|
||||
*/
|
||||
|
||||
#ifndef THEME_ICON_H
|
||||
#define THEME_ICON_H
|
||||
|
||||
#include <QIcon>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QSize>
|
||||
#include <QString>
|
||||
#include <QStringView>
|
||||
|
||||
/**
|
||||
* @brief Builds a palette-aware icon from a `theme:` asset.
|
||||
*
|
||||
* SVG glyphs may use the `currentColor` sentinel. A plain QPixmap snapshot
|
||||
* cannot follow palette changes, so this returns a QIcon whose engine resolves
|
||||
* the sentinel from the current palette every time it is asked for a pixmap.
|
||||
* Icons therefore stay legible when the user switches between light and dark
|
||||
* themes without recreating any actions or buttons.
|
||||
*
|
||||
* @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.
|
||||
* @return An icon that follows the application palette.
|
||||
*/
|
||||
QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::WindowText);
|
||||
|
||||
/**
|
||||
* @brief Renders a resolved theme SVG when it carries the `currentColor` sentinel.
|
||||
*
|
||||
* Non-SVG assets and SVGs without the sentinel return a null pixmap so callers
|
||||
* can fall back to their usual loading path (including legacy inversion).
|
||||
*
|
||||
* @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 size Pixel size to render at; an empty size uses the SVG's intrinsic size.
|
||||
* @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 = {});
|
||||
|
||||
#endif // THEME_ICON_H
|
||||
|
|
@ -12,6 +12,7 @@ set(UTILITY_SOURCES
|
|||
libcockatrice/utility/passwordhasher.cpp
|
||||
libcockatrice/utility/report_categories.cpp
|
||||
libcockatrice/utility/server_rate_limiter.cpp
|
||||
libcockatrice/utility/svg_recolor.cpp
|
||||
libcockatrice/utility/translation_loader.cpp
|
||||
libcockatrice/utility/user_data_backup.cpp
|
||||
libcockatrice/utility/warning_categories.cpp
|
||||
|
|
@ -35,6 +36,7 @@ set(UTILITY_HEADERS
|
|||
libcockatrice/utility/zone_names.h
|
||||
libcockatrice/utility/days_years_between.h
|
||||
libcockatrice/utility/server_rate_limiter.h
|
||||
libcockatrice/utility/svg_recolor.h
|
||||
libcockatrice/utility/translation_loader.h
|
||||
libcockatrice/utility/user_data_backup.h
|
||||
libcockatrice/utility/warning_categories.h
|
||||
|
|
|
|||
15
libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp
Normal file
15
libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#include "svg_recolor.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color)
|
||||
{
|
||||
const QString source = QString::fromUtf8(svg);
|
||||
if (!source.contains(QStringLiteral("currentColor"), Qt::CaseInsensitive)) {
|
||||
return svg;
|
||||
}
|
||||
|
||||
QString resolved = source;
|
||||
resolved.replace(QStringLiteral("currentColor"), QString::fromLatin1(color), Qt::CaseInsensitive);
|
||||
return resolved.toUtf8();
|
||||
}
|
||||
26
libcockatrice_utility/libcockatrice/utility/svg_recolor.h
Normal file
26
libcockatrice_utility/libcockatrice/utility/svg_recolor.h
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* @file svg_recolor.h
|
||||
* @ingroup Core
|
||||
*/
|
||||
|
||||
#ifndef SVG_RECOLOR_H
|
||||
#define SVG_RECOLOR_H
|
||||
|
||||
#include <QByteArray>
|
||||
|
||||
/**
|
||||
* @brief Resolves the `currentColor` sentinel in authored SVG data.
|
||||
*
|
||||
* Qt's SVG renderer has no CSS cascade or ambient color context, so the
|
||||
* sentinel is not resolved by Qt. Callers pass the concrete color they want
|
||||
* (for example a QPalette role serialized to #RRGGBB) and receive SVG bytes
|
||||
* that are safe to hand to QSvgRenderer.
|
||||
*
|
||||
* @param svg UTF-8 SVG source. Every `currentColor` occurrence, in any case,
|
||||
* is replaced.
|
||||
* @param color Concrete SVG color (for example "#ff00ff"), as ASCII bytes.
|
||||
* @return The recolored SVG source; the input is left untouched.
|
||||
*/
|
||||
QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color);
|
||||
|
||||
#endif // SVG_RECOLOR_H
|
||||
|
|
@ -30,6 +30,7 @@ set(oracle_SOURCES
|
|||
../cockatrice/src/client/network/update/client/release_channel.cpp
|
||||
../cockatrice/src/interface/pixel_map_generator.cpp
|
||||
../cockatrice/src/interface/theme_config.cpp
|
||||
../cockatrice/src/interface/theme_icon.cpp
|
||||
../cockatrice/src/interface/theme_manager.cpp
|
||||
${VERSION_STRING_CPP}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIB
|
|||
cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME svg_recolor_test SOURCES svg_recolor_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
|
|
|
|||
53
tests/svg_recolor_test.cpp
Normal file
53
tests/svg_recolor_test.cpp
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
TEST(SvgRecolor, ReplacesEverySentinel)
|
||||
{
|
||||
const QByteArray svg = R"(<svg><path fill="currentColor"/><path stroke="currentColor"/></svg>)";
|
||||
const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800");
|
||||
|
||||
EXPECT_EQ(result.count("#ff8800"), 2);
|
||||
EXPECT_FALSE(result.contains("currentColor"));
|
||||
}
|
||||
|
||||
TEST(SvgRecolor, ReplacesSentinelRegardlessOfCase)
|
||||
{
|
||||
const QByteArray svg = R"(<svg><path fill="CurrentColor"/><path stroke="CURRENTCOLOR"/></svg>)";
|
||||
|
||||
EXPECT_EQ(resolveSvgColorSentinel(svg, "#ff8800").count("#ff8800"), 2);
|
||||
}
|
||||
|
||||
TEST(SvgRecolor, LeavesTheRestOfTheDocumentIntact)
|
||||
{
|
||||
const QByteArray svg =
|
||||
R"(<svg viewBox="0 0 24 24"><!-- an ordinary comment --><path d="M0 0h24v24" fill="currentColor"/></svg>)";
|
||||
const QByteArray result = resolveSvgColorSentinel(svg, "#123456");
|
||||
|
||||
EXPECT_TRUE(result.contains(R"(viewBox="0 0 24 24")"));
|
||||
EXPECT_TRUE(result.contains(R"(d="M0 0h24v24")"));
|
||||
EXPECT_TRUE(result.contains("an ordinary comment"));
|
||||
}
|
||||
|
||||
TEST(SvgRecolor, DoesNotModifyTheInput)
|
||||
{
|
||||
const QByteArray svg = R"(<svg><path fill="currentColor"/></svg>)";
|
||||
const QByteArray original = svg;
|
||||
|
||||
resolveSvgColorSentinel(svg, "#000000");
|
||||
|
||||
EXPECT_EQ(svg, original);
|
||||
}
|
||||
|
||||
TEST(SvgRecolor, NoSentinelIsANoOp)
|
||||
{
|
||||
const QByteArray svg = R"(<svg><path fill="#abcdef"/></svg>)";
|
||||
|
||||
EXPECT_EQ(resolveSvgColorSentinel(svg, "#ffffff"), svg);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
Loading…
Add table
Add a link
Reference in a new issue