diff --git a/src/duckstation-qt/CMakeLists.txt b/src/duckstation-qt/CMakeLists.txt index ae4afa320..6da572f19 100644 --- a/src/duckstation-qt/CMakeLists.txt +++ b/src/duckstation-qt/CMakeLists.txt @@ -166,6 +166,8 @@ set(SRCS setupwizarddialog.cpp setupwizarddialog.h setupwizarddialog.ui + svgwidget.cpp + svgwidget.h texturereplacementsettingsdialog.ui themesvgiconengine.cpp themesvgiconengine.h diff --git a/src/duckstation-qt/duckstation-qt.vcxproj b/src/duckstation-qt/duckstation-qt.vcxproj index 4e9897b4e..9048eddd4 100644 --- a/src/duckstation-qt/duckstation-qt.vcxproj +++ b/src/duckstation-qt/duckstation-qt.vcxproj @@ -55,14 +55,17 @@ + NotUsing - + + + diff --git a/src/duckstation-qt/duckstation-qt.vcxproj.filters b/src/duckstation-qt/duckstation-qt.vcxproj.filters index 426367f23..df36ff6ad 100644 --- a/src/duckstation-qt/duckstation-qt.vcxproj.filters +++ b/src/duckstation-qt/duckstation-qt.vcxproj.filters @@ -55,6 +55,7 @@ + @@ -122,6 +123,7 @@ + diff --git a/src/duckstation-qt/svgwidget.cpp b/src/duckstation-qt/svgwidget.cpp new file mode 100644 index 000000000..e8d678b97 --- /dev/null +++ b/src/duckstation-qt/svgwidget.cpp @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: 2019-2026 Connor McLaughlin +// SPDX-License-Identifier: CC-BY-NC-ND-4.0 + +#include "svgwidget.h" +#include "qtutils.h" + +#include +#include +#include +#include + +#include +#include + +#include "moc_svgwidget.cpp" + +static void CleanupSurface(void* surface) +{ + plutovg_surface_destroy(static_cast(surface)); +} + +SVGWidget::SVGWidget(QWidget* parent) : QWidget(parent) +{ + setAttribute(Qt::WA_OpaquePaintEvent, false); +} + +SVGWidget::SVGWidget(const QString& resource_path, QWidget* parent /*= nullptr*/) : SVGWidget(parent) +{ + setSource(resource_path); +} + +SVGWidget::~SVGWidget() +{ + destroyDocument(); +} + +void SVGWidget::setColor(const QColor& color) +{ + m_pixmap = QPixmap(); + rasterize(); + update(); +} + +void SVGWidget::setSource(const QString& resource_path) +{ + destroyDocument(); + m_resource_path = resource_path; + m_pixmap = {}; + m_last_raster_size = {}; + + if (resource_path.isEmpty()) + { + update(); + return; + } + + QFile file(m_resource_path); + if (!file.open(QFile::ReadOnly) || !QtUtils::ReadFileToByteArray(&file, m_svg_data)) + { + qCritical() << "Failed to open SVG file: " << m_resource_path; + update(); + return; + } + + // plutosvg borrows the raw pointer; m_svg_data must outlive m_document. + 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() << "PlutoSVGWidget: failed to parse SVG" << resource_path; + m_svg_data.deallocate(); + m_resource_path.clear(); + update(); + return; + } + + rasterize(); + update(); +} + +void SVGWidget::destroyDocument() +{ + if (m_document) + { + plutosvg_document_destroy(m_document); + m_document = nullptr; + } + m_svg_data.deallocate(); +} + +void SVGWidget::rasterize() +{ + m_pixmap = {}; + + if (!m_document || size().isEmpty()) + return; + + const qreal dpr = devicePixelRatioF(); + + // Physical pixel dimensions to render at. + const QSize physical = QtUtils::ApplyDevicePixelRatioToSize(size(), dpr); + + // Avoid redundant re-renders when size hasn't actually changed. + if (physical == m_last_raster_size && !m_pixmap.isNull()) + return; + + m_last_raster_size = physical; + + // Determine SVG intrinsic size and compute a uniform scale that fits inside physical, + // preserving aspect ratio. + const float svg_w = plutosvg_document_get_width(m_document); + const float svg_h = plutosvg_document_get_height(m_document); + + int render_w = physical.width(); + int render_h = physical.height(); + + if (svg_w > 0.0f && svg_h > 0.0f) + { + // Scale to fit, preserving aspect ratio (letterbox / pillarbox). + const float scale_x = static_cast(physical.width()) / svg_w; + const float scale_y = static_cast(physical.height()) / svg_h; + const float scale = std::min(scale_x, scale_y); + render_w = std::max(1, static_cast(svg_w * scale)); + render_h = std::max(1, static_cast(svg_h * scale)); + } + + // Use white as currentColor so the SVG's own colours are preserved. + // Swap in a palette colour here if tinting is ever desired. + const plutovg_color_t current_color = {.r = 1.0f, .g = 1.0f, .b = 1.0f, .a = 1.0f}; + + plutovg_surface_t* const surface = + plutosvg_document_render_to_surface(m_document, nullptr, render_w, render_h, ¤t_color, nullptr, nullptr); + if (!surface) + { + qCritical() << "PlutoSVGWidget: render failed for" << m_resource_path; + return; + } + + // plutovg surfaces are premultiplied ARGB (native-endian) == QImage::Format_ARGB32_Premultiplied. + 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, CleanupSurface, surface); + + m_pixmap = QPixmap::fromImage(img); + m_pixmap.setDevicePixelRatio(dpr); +} + +void SVGWidget::paintEvent(QPaintEvent* event) +{ + Q_UNUSED(event); + + QPainter painter(this); + + if (m_pixmap.isNull()) + return; + + // Center the (possibly smaller, aspect-ratio-corrected) pixmap inside the widget. + // m_pixmap.size() is in physical pixels; divide by DPR to get logical pixels. + const QSizeF logical_size = m_pixmap.size() / m_pixmap.devicePixelRatioF(); + const QPointF top_left((width() - logical_size.width()) / 2.0, (height() - logical_size.height()) / 2.0); + + painter.drawPixmap(QRectF(top_left, logical_size), m_pixmap, QRectF(QPointF(0, 0), m_pixmap.size())); +} + +void SVGWidget::resizeEvent(QResizeEvent* event) +{ + QWidget::resizeEvent(event); + rasterize(); +} + +void SVGWidget::changeEvent(QEvent* event) +{ + QWidget::changeEvent(event); + + // Re-rasterize when the screen changes (e.g. window moved to a display with a different DPR). + if (event->type() == QEvent::DevicePixelRatioChange || event->type() == QEvent::ScreenChangeInternal) + { + m_last_raster_size = {}; // force re-render + rasterize(); + update(); + } +} \ No newline at end of file diff --git a/src/duckstation-qt/svgwidget.h b/src/duckstation-qt/svgwidget.h new file mode 100644 index 000000000..074591442 --- /dev/null +++ b/src/duckstation-qt/svgwidget.h @@ -0,0 +1,61 @@ +// 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 + +struct plutosvg_document; + +/** + * A widget that loads a monochrome SVG file via plutosvg and rasterizes it at the correct device + * pixel ratio. The SVG is re-rasterized whenever the widget is resized so the image stays crisp + * at any size. The rendered image is centered inside the widget; if the SVG aspect ratio differs + * from the widget's aspect ratio the image is letterboxed / pillarboxed with a transparent + * background. + */ +class SVGWidget final : public QWidget +{ + Q_OBJECT + +public: + explicit SVGWidget(QWidget* parent = nullptr); + SVGWidget(const QString& resource_path, QWidget* parent = nullptr); + ~SVGWidget() override; + + const QColor& color() const { return m_color; } + void setColor(const QColor& color); + + /// Load (or reload) an SVG from the given Qt resource / file path. + /// Clears any previously loaded document. Triggers a repaint. + void setSource(const QString& resource_path); + + /// Returns the path that was passed to setSource(). + const QString& source() const { return m_resource_path; } + +protected: + void paintEvent(QPaintEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + void changeEvent(QEvent* event) override; + +private: + /// (Re)render m_document at the current widget size + DPR and store the result in m_pixmap. + void rasterize(); + + /// Free the parsed document and clear associated data. + void destroyDocument(); + + QString m_resource_path; + DynamicHeapArray m_svg_data; ///< Raw bytes; plutosvg borrows this pointer. + plutosvg_document* m_document = nullptr; + QPixmap m_pixmap; ///< Last rasterized pixmap (physical pixels, DPR set). + QSize m_last_raster_size; ///< Physical size used for the last rasterize() call. + QColor m_color; ///< Color to use when rasterizing the SVG (overrides currentColor in the SVG). +}; \ No newline at end of file