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("