diff --git a/dep/vsprops/QtCompile.props b/dep/vsprops/QtCompile.props index cb2d1a69c..3f47c6051 100644 --- a/dep/vsprops/QtCompile.props +++ b/dep/vsprops/QtCompile.props @@ -119,10 +119,10 @@ - + - + diff --git a/scripts/appimage/make-appimage.sh b/scripts/appimage/make-appimage.sh index 3a26d564b..4bfb99928 100755 --- a/scripts/appimage/make-appimage.sh +++ b/scripts/appimage/make-appimage.sh @@ -104,7 +104,7 @@ for i in $(find "$DEPSDIR" -iname '*.so'); do done echo "Running linuxdeploy to create AppDir..." -EXTRA_QT_MODULES="core;gui;svg;widgets;xcbqpa;waylandcompositor" \ +EXTRA_QT_MODULES="core;gui;widgets;xcbqpa;waylandcompositor" \ EXTRA_PLATFORM_PLUGINS="libqwayland.so" \ DEPLOY_PLATFORM_THEMES="1" \ LINUXDEPLOY_EXCLUDED_LIBRARIES="libwayland-cursor*;libwayland-egl*" \ diff --git a/scripts/appimage/make-cross-appimage.sh b/scripts/appimage/make-cross-appimage.sh index c5f868468..d3f6aabd8 100755 --- a/scripts/appimage/make-cross-appimage.sh +++ b/scripts/appimage/make-cross-appimage.sh @@ -177,14 +177,12 @@ declare -a QTLIBS=( "libQt6DBus.so.6" "libQt6Gui.so.6" "libQt6OpenGL.so.6" - "libQt6Svg.so.6" "libQt6WaylandClient.so.6" "libQt6Widgets.so.6" "libQt6XcbQpa.so.6" ) declare -a QTPLUGINS=( - "plugins/iconengines" "plugins/imageformats" "plugins/platforminputcontexts" "plugins/platforms" diff --git a/src/duckstation-qt/CMakeLists.txt b/src/duckstation-qt/CMakeLists.txt index de968026e..ae4afa320 100644 --- a/src/duckstation-qt/CMakeLists.txt +++ b/src/duckstation-qt/CMakeLists.txt @@ -167,6 +167,8 @@ set(SRCS setupwizarddialog.h setupwizarddialog.ui texturereplacementsettingsdialog.ui + themesvgiconengine.cpp + themesvgiconengine.h togglebutton.cpp togglebutton.h ) @@ -205,6 +207,10 @@ endif() # Our Qt builds may have exceptions on, so force them off. target_compile_definitions(duckstation-qt PRIVATE QT_NO_EXCEPTIONS QT_NO_SIGNALS_SLOTS_KEYWORDS) +# We need to override QT_STATICPLUGIN to force our SVG plugin to be statically linked to the exectuable. +# Prevent the force include of the pch from interfering. +set_source_files_properties("themesvgiconengine.cpp" PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) + add_core_resources(duckstation-qt) # Automatically generate a list of .ui calls and call qt_wrap_ui() to generate targets. diff --git a/src/duckstation-qt/duckstation-qt.vcxproj b/src/duckstation-qt/duckstation-qt.vcxproj index f71b02469..4e9897b4e 100644 --- a/src/duckstation-qt/duckstation-qt.vcxproj +++ b/src/duckstation-qt/duckstation-qt.vcxproj @@ -55,10 +55,15 @@ + + NotUsing + + + @@ -331,6 +336,7 @@ Document + diff --git a/src/duckstation-qt/duckstation-qt.vcxproj.filters b/src/duckstation-qt/duckstation-qt.vcxproj.filters index 13b80fa54..426367f23 100644 --- a/src/duckstation-qt/duckstation-qt.vcxproj.filters +++ b/src/duckstation-qt/duckstation-qt.vcxproj.filters @@ -54,6 +54,7 @@ + @@ -120,6 +121,7 @@ + @@ -200,6 +202,7 @@ translations + diff --git a/src/duckstation-qt/qthost.cpp b/src/duckstation-qt/qthost.cpp index 80f6cb44d..aa085f60c 100644 --- a/src/duckstation-qt/qthost.cpp +++ b/src/duckstation-qt/qthost.cpp @@ -62,6 +62,7 @@ #include #include #include +#include #include #include #include @@ -95,6 +96,9 @@ QT_TRANSLATE_NOOP("MAC_APPLICATION_MENU", "Quit %1") QT_TRANSLATE_NOOP("MAC_APPLICATION_MENU", "About %1") #endif +Q_IMPORT_PLUGIN(ThemeSVGIconEnginePlugin); +Q_IMPORT_PLUGIN(PlutoSVGImagePlugin); + static constexpr u32 SETTINGS_SAVE_DELAY = 1000; /// Interval at which the controllers are polled when the system is not active. @@ -272,6 +276,7 @@ bool QtHost::EarlyProcessStartup() icon_theme_search_paths.emplace_back(":/icons"_L1); icon_theme_search_paths.emplace_back(":/standard-icons"_L1); QIcon::setThemeSearchPaths(icon_theme_search_paths); + QIcon::setThemeName("monochrome"); return true; } @@ -929,6 +934,34 @@ void QtHost::ApplyMigrations() } } } + +#ifdef _WIN32 +#ifdef _DEBUG +#define SUFFIX "d" +#else +#define SUFFIX +#endif + // Remove Qt6Svg.dll and the plugins which use QtSvg, since the updater can't remove them itself... + for (const char* path_to_remove : { + "Qt6Svg" SUFFIX ".dll", + "QtPlugins\\iconengines\\qsvgicon" SUFFIX ".dll", + "QtPlugins\\imageformats\\qsvg" SUFFIX ".dll", + }) + { + const std::string full_path = Path::Combine(EmuFolders::AppRoot, path_to_remove); + if (FileSystem::FileExists(full_path.c_str())) + { + Error error; + if (!FileSystem::DeleteFile(full_path.c_str(), &error)) + { + QMessageBox::critical(nullptr, "Error"_L1, + QString::fromStdString(fmt::format("Failed to delete conflicting library {}: {}", + path_to_remove, error.GetDescription()))); + } + } + } +#undef SUFFIX +#endif } void CoreThread::applySettings(bool display_osd_messages /* = false */) diff --git a/src/duckstation-qt/qtthemes.cpp b/src/duckstation-qt/qtthemes.cpp index b1e9dbeef..643aedf31 100644 --- a/src/duckstation-qt/qtthemes.cpp +++ b/src/duckstation-qt/qtthemes.cpp @@ -326,10 +326,6 @@ bool QtHost::HasGlobalStylesheet() void QtHost::UpdateThemeOnStyleChange() { - const QLatin1StringView new_theme_name = IsDarkApplicationTheme() ? "white"_L1 : "black"_L1; - if (QIcon::themeName() != new_theme_name) - QIcon::setThemeName(new_theme_name); - if (NativeThemeStylesheetNeedsUpdate()) { const QString stylesheet = GetNativeThemeStylesheet(); diff --git a/src/duckstation-qt/themesvgiconengine.cpp b/src/duckstation-qt/themesvgiconengine.cpp new file mode 100644 index 000000000..77c4fd89d --- /dev/null +++ b/src/duckstation-qt/themesvgiconengine.cpp @@ -0,0 +1,372 @@ +// SPDX-FileCopyrightText: 2019-2026 Connor McLaughlin +// SPDX-License-Identifier: CC-BY-NC-ND-4.0 + +// Needs to be defined before any includes. +#define QT_STATICPLUGIN + +#include "themesvgiconengine.h" +#include "qtutils.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "moc_themesvgiconengine.cpp" + +/// Returns the icon color for the given mode based on the application palette. +static QColor GetIconColorFromPalette(QIcon::Mode mode) +{ + // Thank gosh these are copy-on-write... + const QPalette palette = QApplication::palette(); + + // NOTE: Active and Normal are the same in QPalette. + if (mode == QIcon::Disabled) + return palette.color(QPalette::Disabled, QPalette::WindowText); + else if (mode == QIcon::Selected) + return palette.color(QPalette::Current, QPalette::HighlightedText); + else + return palette.color(QPalette::Normal, QPalette::WindowText); +} + +/// Constructs a cache key for the given resource path, pixel size, and RGBA color value. +static QString BuildCacheKey(const QString& resource_path, const QSize& size, qreal dpr, const QColor& color) +{ + // Cache key includes path, size, and colour so stale entries are not reused after a palette change. + return QStringLiteral("%1_%2x%3@%4_%5") + .arg(resource_path) + .arg(size.width()) + .arg(size.height()) + .arg(dpr) + .arg(static_cast(color.rgba()), 8, 16, QLatin1Char('0')); +} + +/// Callback used when freeing the QImage. +static void CleanupPlutoSVGSurface(void* surface) +{ + plutovg_surface_destroy(static_cast(surface)); +} + +/// Renders the document at the given pixel dimensions with the given colour and inserts the +/// result into QPixmapCache under cache_key. Returns a null QPixmap on failure. +static bool RenderSVGToPixmap(QPixmap& pm, const plutosvg_document* doc, const QSize& size, const QColor& color) +{ + // Pass the desired icon colour as CSS currentColor so plutosvg tints the monochrome SVG in a + // single render pass, avoiding a separate SourceIn compositing step. + const plutovg_color_t current_color = { + .r = static_cast(color.redF()), + .g = static_cast(color.greenF()), + .b = static_cast(color.blueF()), + .a = static_cast(color.alphaF()), + }; + + plutovg_surface_t* surface = + plutosvg_document_render_to_surface(doc, nullptr, size.width(), size.height(), ¤t_color, nullptr, nullptr); + if (!surface) + return false; + + // plutovg surfaces are premultiplied ARGB (0xAARRGGBB, native-endian), which matches + // QImage::Format_ARGB32_Premultiplied exactly, no pixel conversion required. + // The cleanup function ensures the surface is destroyed when the QImage is done with the pixel data. + const QImage img(plutovg_surface_get_data(surface), plutovg_surface_get_width(surface), + plutovg_surface_get_height(surface), plutovg_surface_get_stride(surface), + QImage::Format_ARGB32_Premultiplied, CleanupPlutoSVGSurface, surface); + + pm = QPixmap::fromImage(img); + return !pm.isNull(); +} + +/// Helper function to read a QFile to a DynamicHeapArray. +static bool ReadFileToByteArray(QIODevice* dev, DynamicHeapArray& out_data) +{ + if (qint64 size; !dev->isSequential() && (size = dev->size()) > 0) + { + out_data.resize(static_cast( + (sizeof(size_t) == sizeof(qint64)) ? size : std::min(size, std::numeric_limits::max()))); + + if (dev->read(reinterpret_cast(out_data.data()), size) != size) + { + out_data.deallocate(); + return false; + } + } + else + { + constexpr size_t chunk_size = 1048576; + size_t read_so_far = 0; + for (;;) + { + const size_t prev_size = out_data.size(); + const size_t new_size = + ((read_so_far + chunk_size) < read_so_far) ? std::numeric_limits::max() : (read_so_far + chunk_size); + const size_t space = (new_size - prev_size); + if (space > 0) + out_data.resize(new_size); + const qint64 bytes_read = + (space > 0) ? dev->read(reinterpret_cast(out_data.data() + read_so_far), static_cast(space)) : 0; + if (bytes_read < 0) + { + out_data.deallocate(); + return false; + } + else if (bytes_read == 0) + { + out_data.resize(prev_size); + break; + } + + read_so_far += static_cast(bytes_read); + } + } + + return true; +} + +ThemeSVGIconEngine::ThemeSVGIconEngine(const QString& resource_path) : m_resource_path(resource_path) +{ +} + +ThemeSVGIconEngine::~ThemeSVGIconEngine() +{ + if (m_document) + plutosvg_document_destroy(m_document); +} + +bool ThemeSVGIconEngine::ensureLoaded() const +{ + // Previous load failed? + if (m_resource_path.isEmpty()) + return false; + + QFile file(m_resource_path); + if (!file.open(QFile::ReadOnly) || !ReadFileToByteArray(&file, m_svg_data)) + { + qCritical() << "Failed to open SVG file: " << m_resource_path; + m_resource_path = {}; + return false; + } + + // The document borrows the data pointer; m_svg_data must remain valid for the document's lifetime. + m_document = plutosvg_document_load_from_data(reinterpret_cast(m_svg_data.data()), + static_cast(m_svg_data.size()), -1.0f, -1.0f, nullptr, nullptr); + if (!m_document) + { + qCritical() << "Failed to parse SVG data: " << m_resource_path; + m_resource_path = {}; + return false; + } + + return true; +} + +void ThemeSVGIconEngine::paint(QPainter* painter, const QRect& rect, QIcon::Mode mode, QIcon::State state) +{ + Q_UNUSED(state); + + if (rect.isEmpty()) + return; + + // Apply device pixel ratio to requested size so we can cache pixmaps at the correct sizes for different DPRs. + const QPaintDevice* const device = painter->device(); + const qreal dpr = device ? device->devicePixelRatio() : 1.0; + const QSize size = QtUtils::ApplyDevicePixelRatioToSize(rect.size(), dpr); + const QColor color = GetIconColorFromPalette(mode); + const QString cache_key = BuildCacheKey(m_resource_path, size, dpr, color); + + QPixmap pm; + if (!QPixmapCache::find(cache_key, &pm)) + { + // Don't reload multiple times if we hit the cache. + if (ensureLoaded() && RenderSVGToPixmap(pm, m_document, size, color)) + { + // DPR set must be before inserting into the cache, otherwise it copies. + pm.setDevicePixelRatio(dpr); + QPixmapCache::insert(cache_key, pm); + } + } + + painter->drawPixmap(rect, pm); +} + +QPixmap ThemeSVGIconEngine::pixmap(const QSize& size, QIcon::Mode mode, QIcon::State state) +{ + Q_UNUSED(state); + + if (size.isEmpty()) + return {}; + + const QColor color = GetIconColorFromPalette(mode); + const QString cache_key = BuildCacheKey(m_resource_path, size, 1.0, color); + + QPixmap pm; + if (!QPixmapCache::find(cache_key, &pm)) + { + // Don't reload multiple times if we hit the cache. + if (ensureLoaded() && RenderSVGToPixmap(pm, m_document, size, color)) + QPixmapCache::insert(cache_key, pm); + } + + return pm; +} + +QPixmap ThemeSVGIconEngine::scaledPixmap(const QSize& size, QIcon::Mode mode, QIcon::State state, qreal scale) +{ + Q_UNUSED(state); + + const QSize scaled_size = QtUtils::ApplyDevicePixelRatioToSize(size, scale); + if (scaled_size.isEmpty()) + return {}; + + const QColor color = GetIconColorFromPalette(mode); + const QString cache_key = BuildCacheKey(m_resource_path, scaled_size, scale, color); + + QPixmap pm; + if (!QPixmapCache::find(cache_key, &pm)) + { + // Don't reload multiple times if we hit the cache. + if (ensureLoaded() && RenderSVGToPixmap(pm, m_document, scaled_size, color)) + { + pm.setDevicePixelRatio(scale); + QPixmapCache::insert(cache_key, pm); + } + } + + return pm; +} + +QIconEngine* ThemeSVGIconEngine::clone() const +{ + return new ThemeSVGIconEngine(m_resource_path); +} + +QString ThemeSVGIconEngine::key() const +{ + return QStringLiteral("svg"); +} + +QString ThemeSVGIconEngine::iconName() +{ + return m_resource_path; +} + +QIconEngine* ThemeSVGIconEnginePlugin::create(const QString& resource_path) +{ + if (resource_path.endsWith(".svg", Qt::CaseInsensitive)) + return new ThemeSVGIconEngine(resource_path); + + return nullptr; +} + +bool PlutoSVGImageHandler::canRead() const +{ + if (m_document) + return m_document; + + // Read all data once and keep it alive; plutosvg_document borrows the raw pointer. + QIODevice* const dev = device(); + if (!dev || !ReadFileToByteArray(dev, m_svg_data)) + { + qCritical() << "Failed to read SVG data from device"; + return false; + } + + m_document = plutosvg_document_load_from_data(reinterpret_cast(m_svg_data.data()), + static_cast(m_svg_data.size()), -1.0f, -1.0f, nullptr, nullptr); + if (!m_document) + { + qCritical() << "Failed to parse SVG data"; + m_svg_data = {}; + return false; + } + + return true; +} + +bool PlutoSVGImageHandler::read(QImage* image) +{ + if (!canRead()) + return false; + + // Determine render size: honour caller's ScaledSize, fall back to intrinsic SVG dimensions. + QSize render_size = m_scaled_size; + if (!render_size.isValid()) + { + render_size = QSize(qMax(1, qRound(plutosvg_document_get_width(m_document))), + qMax(1, qRound(plutosvg_document_get_height(m_document)))); + } + + // Render with a solid currentColor so the SVG's own fill/stroke colours are preserved. + // Callers that want palette tinting should use ThemeSVGIconEngine instead. + const plutovg_color_t current_color = {.r = 1.0f, .g = 1.0f, .b = 1.0f, .a = 1.0f}; + + plutovg_surface_t* surface = plutosvg_document_render_to_surface( + m_document, nullptr, render_size.width(), render_size.height(), ¤t_color, nullptr, nullptr); + if (!surface) + return false; + + // Wrap the surface pixels in a QImage; the cleanup function destroys the surface once Qt is done. + // plutovg uses premultiplied ARGB (0xAARRGGBB, native-endian) == QImage::Format_ARGB32_Premultiplied. + *image = + QImage(plutovg_surface_get_data(surface), plutovg_surface_get_width(surface), plutovg_surface_get_height(surface), + plutovg_surface_get_stride(surface), QImage::Format_ARGB32_Premultiplied, CleanupPlutoSVGSurface, surface); + + return !image->isNull(); +} + +bool PlutoSVGImageHandler::supportsOption(ImageOption option) const +{ + return (option == Size || option == ScaledSize); +} + +QVariant PlutoSVGImageHandler::option(ImageOption option) const +{ + if (option == ScaledSize) + return m_scaled_size; + + if (option == Size) + { + if (!canRead()) + return {}; + + return QSize(qMax(1, qRound(plutosvg_document_get_width(m_document))), + qMax(1, qRound(plutosvg_document_get_height(m_document)))); + } + + return {}; +} + +void PlutoSVGImageHandler::setOption(ImageOption option, const QVariant& value) +{ + if (option == ScaledSize) + m_scaled_size = value.toSize(); +} + +QImageIOPlugin::Capabilities PlutoSVGImagePlugin::capabilities(QIODevice* device, const QByteArray& format) const +{ + if (format == "svg") + return CanRead; + + if (device) + { + // Require the byte sequence to contain an XML or SVG marker within a reasonable prefix. + const QByteArray prefix = device->peek(256); + if (prefix.contains("setDevice(device); + handler->setFormat(format.isEmpty() ? QByteArray("svg") : format); + return handler; +} diff --git a/src/duckstation-qt/themesvgiconengine.h b/src/duckstation-qt/themesvgiconengine.h new file mode 100644 index 000000000..2cd3cc408 --- /dev/null +++ b/src/duckstation-qt/themesvgiconengine.h @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2019-2026 Connor McLaughlin +// SPDX-License-Identifier: CC-BY-NC-ND-4.0 + +#pragma once + +#include "common/heap_array.h" +#include "common/types.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +struct plutosvg_document; + +/** + * Custom icon engine that renders SVG icons tinted to the current palette's WindowText colour. + * This allows a single set of monochrome SVG files to serve both light and dark themes without + * duplication. The SVG is parsed once into a plutosvg_document and cached on the engine instance. + * At paint/pixmap time plutosvg renders directly to a premultiplied-ARGB surface using the desired + * palette colour as CSS currentColor, so no secondary SourceIn compositing pass is required. + * + * Pixmaps are cached in QPixmapCache keyed by resource path, size (in device pixels), and the RGBA + * value of the resolved colour, so the cache is automatically invalidated when the palette changes. + */ +class ThemeSVGIconEngine final : public QIconEngine +{ +public: + explicit ThemeSVGIconEngine(const QString& resource_path); + ThemeSVGIconEngine(const ThemeSVGIconEngine&) = delete; + ~ThemeSVGIconEngine() override; + + void paint(QPainter* painter, const QRect& rect, QIcon::Mode mode, QIcon::State state) override; + QPixmap pixmap(const QSize& size, QIcon::Mode mode, QIcon::State state) override; + QPixmap scaledPixmap(const QSize& size, QIcon::Mode mode, QIcon::State state, qreal scale) override; + QIconEngine* clone() const override; + QString key() const override; + QString iconName() override; + +private: + /// Ensures the SVG file has been loaded. + bool ensureLoaded() const; + + mutable QString m_resource_path; + mutable DynamicHeapArray m_svg_data; + mutable plutosvg_document* m_document = nullptr; +}; + +class ThemeSVGIconEnginePlugin : public QIconEnginePlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID QIconEngineFactoryInterface_iid FILE "themesvgiconengine.json") + +public: + QIconEngine* create(const QString& resource_path) override; +}; + +/** + * Qt image I/O handler — replaces the QtSvg SVG image loader. + * + * Registered statically for "svg" so that any Qt code that loads SVG images + * through QImageReader (e.g. QPixmap::load, QImage::load) will use plutosvg + * instead of QtSvg's built-in rasteriser. + * + * Callers can influence the output size through the standard QImageIOHandler + * ScaledSize option; if not set the SVG's intrinsic dimensions are used. + */ +class PlutoSVGImageHandler final : public QImageIOHandler +{ +public: + PlutoSVGImageHandler() = default; + ~PlutoSVGImageHandler() override = default; + + /// Lazily load and parse the SVG data from device(). Returns false on failure. + bool canRead() const override; + bool read(QImage* image) override; + + bool supportsOption(ImageOption option) const override; + QVariant option(ImageOption option) const override; + void setOption(ImageOption option, const QVariant& value) override; + + static bool canRead(QIODevice* device); + +private: + mutable DynamicHeapArray m_svg_data; + mutable plutosvg_document* m_document = nullptr; + QSize m_scaled_size; +}; + +/// QImageIOPlugin that installs PlutoSVGImageHandler for SVG files. +class PlutoSVGImagePlugin final : public QImageIOPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID QImageIOHandlerFactoryInterface_iid FILE "themesvgiconengine.json") + +public: + Capabilities capabilities(QIODevice* device, const QByteArray& format) const override; + QImageIOHandler* create(QIODevice* device, const QByteArray& format = QByteArray()) const override; +}; diff --git a/src/duckstation-qt/themesvgiconengine.json b/src/duckstation-qt/themesvgiconengine.json new file mode 100644 index 000000000..ed96b50d0 --- /dev/null +++ b/src/duckstation-qt/themesvgiconengine.json @@ -0,0 +1 @@ +{ "Keys": [ "svg" ] } \ No newline at end of file