diff --git a/src/duckstation-qt/gamelistwidget.cpp b/src/duckstation-qt/gamelistwidget.cpp index a5be080c5..a1bb4d10f 100644 --- a/src/duckstation-qt/gamelistwidget.cpp +++ b/src/duckstation-qt/gamelistwidget.cpp @@ -51,7 +51,7 @@ static constexpr int COVER_ART_HEIGHT = 512; static constexpr int COVER_ART_SPACING = 32; static constexpr int MIN_COVER_CACHE_SIZE = 256; -static void resizeAndPadImage(QImage* image, int expected_width, int expected_height) +static void resizeAndPadImage(QImage* image, int expected_width, int expected_height, bool fill_with_top_left) { const qreal dpr = image->devicePixelRatio(); const int dpr_expected_width = static_cast(static_cast(expected_width) * dpr); @@ -59,10 +59,15 @@ static void resizeAndPadImage(QImage* image, int expected_width, int expected_he if (image->width() == dpr_expected_width && image->height() == dpr_expected_height) return; - if (image->width() > image->height()) + if ((static_cast(image->width()) / static_cast(image->height())) >= + (static_cast(dpr_expected_width) / static_cast(dpr_expected_height))) + { *image = image->scaledToWidth(dpr_expected_width, Qt::SmoothTransformation); + } else + { *image = image->scaledToHeight(dpr_expected_height, Qt::SmoothTransformation); + } if (image->width() == dpr_expected_width && image->height() == dpr_expected_height) return; @@ -70,14 +75,20 @@ static void resizeAndPadImage(QImage* image, int expected_width, int expected_he // QPainter works in unscaled coordinates. int xoffs = 0; int yoffs = 0; - if (image->width() < dpr_expected_width) - xoffs = static_cast(static_cast((dpr_expected_width - image->width()) / 2) / dpr); - if (image->height() < dpr_expected_height) - yoffs = static_cast(static_cast((dpr_expected_height - image->height()) / 2) / dpr); + const int image_width = image->width(); + const int image_height = image->height(); + if (image_width < dpr_expected_width) + xoffs = static_cast(static_cast((dpr_expected_width - image_width) / 2) / dpr); + if (image_height < dpr_expected_height) + yoffs = static_cast(static_cast((dpr_expected_height - image_height) / 2) / dpr); QImage padded_image(dpr_expected_width, dpr_expected_height, QImage::Format_ARGB32); padded_image.setDevicePixelRatio(dpr); - padded_image.fill(Qt::transparent); + if (fill_with_top_left) + padded_image.fill(image->pixel(0, 0)); + else + padded_image.fill(Qt::transparent); + QPainter painter; if (painter.begin(&padded_image)) { @@ -89,67 +100,6 @@ static void resizeAndPadImage(QImage* image, int expected_width, int expected_he *image = std::move(padded_image); } -GameListCoverLoader::GameListCoverLoader(const GameList::Entry* ge, const QImage& placeholder_image, int width, - int height, float scale) - : QObject(nullptr), m_path(ge->path), m_serial(ge->serial), m_title(ge->title), - m_placeholder_image(placeholder_image), m_width(width), m_height(height), m_scale(scale), - m_dpr(qApp->devicePixelRatio()) -{ -} - -GameListCoverLoader::~GameListCoverLoader() = default; - -void GameListCoverLoader::loadOrGenerateCover() -{ - QPixmap image; - const std::string cover_path(GameList::GetCoverImagePath(m_path, m_serial, m_title)); - if (!cover_path.empty()) - { - m_image.load(QString::fromStdString(cover_path)); - if (!m_image.isNull()) - { - m_image.setDevicePixelRatio(m_dpr); - resizeAndPadImage(&m_image, m_width, m_height); - } - } - - if (m_image.isNull()) - createPlaceholderImage(); - - // Have to pass through the UI thread, because the thread pool isn't a QThread... - // Can't create pixmaps on the worker thread, have to create it on the UI thread. - QtHost::RunOnUIThread([this]() { - if (!m_image.isNull()) - emit coverLoaded(m_path, m_image, m_scale); - else - emit coverLoaded(m_path, m_image, m_scale); - delete this; - }); -} - -void GameListCoverLoader::createPlaceholderImage() -{ - m_image = m_placeholder_image.copy(); - if (m_image.isNull()) - return; - - resizeAndPadImage(&m_image, m_width, m_height); - - QPainter painter; - if (painter.begin(&m_image)) - { - QFont font; - font.setPointSize(std::max(static_cast(32.0f * m_scale), 1)); - painter.setFont(font); - painter.setPen(Qt::white); - - const QRect text_rc(0, 0, static_cast(static_cast(m_width)), - static_cast(static_cast(m_height))); - painter.drawText(text_rc, Qt::AlignCenter | Qt::TextWordWrap, QString::fromStdString(m_title)); - painter.end(); - } -} - std::optional GameListModel::getColumnIdForName(std::string_view name) { for (int column = 0; column < Column_Count; column++) @@ -208,7 +158,7 @@ void GameListModel::setCoverScale(float scale) if (loading_image.load(QStringLiteral("%1/images/placeholder.png").arg(QtHost::GetResourcesBasePath()))) { loading_image.setDevicePixelRatio(dpr); - resizeAndPadImage(&loading_image, getCoverArtWidth(), getCoverArtHeight()); + resizeAndPadImage(&loading_image, getCoverArtWidth(), getCoverArtHeight(), false); } else { @@ -222,7 +172,7 @@ void GameListModel::setCoverScale(float scale) if (m_placeholder_image.load(QStringLiteral("%1/images/cover-placeholder.png").arg(QtHost::GetResourcesBasePath()))) { m_placeholder_image.setDevicePixelRatio(dpr); - resizeAndPadImage(&m_placeholder_image, getCoverArtWidth(), getCoverArtHeight()); + resizeAndPadImage(&m_placeholder_image, getCoverArtWidth(), getCoverArtHeight(), false); } else { @@ -258,11 +208,57 @@ void GameListModel::reloadThemeSpecificImages() void GameListModel::loadOrGenerateCover(const GameList::Entry* ge) { - // NOTE: Must get connected before queuing, because otherwise you risk a race. - GameListCoverLoader* loader = - new GameListCoverLoader(ge, m_placeholder_image, getCoverArtWidth(), getCoverArtHeight(), m_cover_scale); - connect(loader, &GameListCoverLoader::coverLoaded, this, &GameListModel::coverLoaded); - System::QueueAsyncTask([loader]() { loader->loadOrGenerateCover(); }); + QtAsyncTask::create(this, [path = ge->path, serial = ge->serial, title = ge->title, + placeholder_image = m_placeholder_image, list = this, width = getCoverArtWidth(), + height = getCoverArtHeight(), scale = m_cover_scale, + dpr = qApp->devicePixelRatio()]() mutable { + QImage image; + loadOrGenerateCover(image, placeholder_image, width, height, scale, dpr, path, serial, title); + return [path = std::move(path), image = std::move(image), list, scale]() { list->coverLoaded(path, image, scale); }; + }); +} + +void GameListModel::createPlaceholderImage(QImage& image, const QImage& placeholder_image, int width, int height, + float scale, const std::string& title) +{ + image = placeholder_image.copy(); + if (image.isNull()) + return; + + resizeAndPadImage(&image, width, height, false); + + QPainter painter; + if (painter.begin(&image)) + { + QFont font; + font.setPointSize(std::max(static_cast(32.0f * scale), 1)); + painter.setFont(font); + painter.setPen(Qt::white); + + const QRect text_rc(0, 0, static_cast(static_cast(width)), + static_cast(static_cast(height))); + painter.drawText(text_rc, Qt::AlignCenter | Qt::TextWordWrap, QString::fromStdString(title)); + painter.end(); + } +} + +void GameListModel::loadOrGenerateCover(QImage& image, const QImage& placeholder_image, int width, int height, + float scale, float dpr, const std::string& path, const std::string& serial, + const std::string& title) +{ + const std::string cover_path(GameList::GetCoverImagePath(path, serial, title)); + if (!cover_path.empty()) + { + image.load(QString::fromStdString(cover_path)); + if (!image.isNull()) + { + image.setDevicePixelRatio(dpr); + resizeAndPadImage(&image, width, height, false); + } + } + + if (image.isNull()) + createPlaceholderImage(image, placeholder_image, width, height, scale, title); } void GameListModel::coverLoaded(const std::string& path, const QImage& image, float scale) @@ -1229,6 +1225,7 @@ void GameListWidget::initialize() updateToolbar(); resizeTableViewColumnsToFit(); + updateBackground(true); } bool GameListWidget::isShowingGameList() const @@ -1292,6 +1289,42 @@ void GameListWidget::reloadThemeSpecificImages() m_model->reloadThemeSpecificImages(); } +void GameListWidget::updateBackground(bool reload_image) +{ + std::string path = Host::GetBaseStringSettingValue("UI", "GameListBackgroundPath"); + if (!Path::IsAbsolute(path)) + path = Path::Combine(EmuFolders::DataRoot, path); + + if (reload_image) + { + m_background_image = QImage(); + if (!path.empty() && m_background_image.load(path.c_str())) + m_background_image.setDevicePixelRatio(devicePixelRatio()); + } + + if (m_background_image.isNull()) + { + m_ui.stack->setPalette(palette()); + m_table_view->setAlternatingRowColors(true); + return; + } + + QtAsyncTask::create(this, [image = m_background_image, this, widget_width = m_ui.stack->width(), + widget_height = m_ui.stack->height()]() mutable { + resizeAndPadImage(&image, widget_width, widget_height, true); + return [image = std::move(image), this, widget_width, widget_height]() { + // check for dimensions change + if (widget_width != m_ui.stack->width() || widget_height != m_ui.stack->height()) + return; + + QPalette new_palette(m_ui.stack->palette()); + new_palette.setBrush(QPalette::Base, QPixmap::fromImage(image)); + m_ui.stack->setPalette(new_palette); + m_table_view->setAlternatingRowColors(false); + }; + }); +} + void GameListWidget::onRefreshProgress(const QString& status, int current, int total, float time) { // Avoid spamming the UI on very short refresh (e.g. game exit). @@ -1579,6 +1612,7 @@ void GameListWidget::resizeEvent(QResizeEvent* event) { QWidget::resizeEvent(event); resizeTableViewColumnsToFit(); + updateBackground(false); } void GameListWidget::resizeTableViewColumnsToFit() diff --git a/src/duckstation-qt/gamelistwidget.h b/src/duckstation-qt/gamelistwidget.h index ee444141b..8a186e7c7 100644 --- a/src/duckstation-qt/gamelistwidget.h +++ b/src/duckstation-qt/gamelistwidget.h @@ -104,7 +104,6 @@ Q_SIGNALS: void coverScaleChanged(); private Q_SLOTS: - void coverLoaded(const std::string& path, const QImage& image, float scale); void rowsChanged(const QList& rows); private: @@ -115,6 +114,13 @@ private: void setColumnDisplayNames(); void loadOrGenerateCover(const GameList::Entry* ge); void invalidateCoverForPath(const std::string& path); + void coverLoaded(const std::string& path, const QImage& image, float scale); + + static void loadOrGenerateCover(QImage& image, const QImage& placeholder_image, int width, int height, float scale, + float dpr, const std::string& path, const std::string& serial, + const std::string& title); + static void createPlaceholderImage(QImage& image, const QImage& placeholder_image, int width, int height, float scale, + const std::string& title); const QPixmap& getIconPixmapForEntry(const GameList::Entry* ge) const; const QPixmap& getFlagPixmapForEntry(const GameList::Entry* ge) const; @@ -146,35 +152,6 @@ private: mutable LRUCache m_memcard_pixmap_cache; }; -class GameListCoverLoader : public QObject -{ - Q_OBJECT - -public: - GameListCoverLoader(const GameList::Entry* ge, const QImage& placeholder_image, int width, int height, float scale); - ~GameListCoverLoader(); - -public: - void loadOrGenerateCover(); - -Q_SIGNALS: - void coverLoaded(const std::string& path, const QImage& image, float scale); - -private: - void createPlaceholderImage(); - - std::string m_path; - std::string m_serial; - std::string m_title; - QImage m_placeholder_image; - int m_width; - int m_height; - float m_scale; - float m_dpr; - - QImage m_image; -}; - class GameListGridListView : public QListView { Q_OBJECT @@ -208,6 +185,7 @@ public: void refreshModel(); void cancelRefresh(); void reloadThemeSpecificImages(); + void updateBackground(bool reload_image); bool isShowingGameList() const; bool isShowingGameGrid() const; @@ -277,4 +255,6 @@ private: Ui::EmptyGameListWidget m_empty_ui; GameListRefreshThread* m_refresh_thread = nullptr; + + QImage m_background_image; }; diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp index 3d66a8244..5be9dbcc3 100644 --- a/src/duckstation-qt/mainwindow.cpp +++ b/src/duckstation-qt/mainwindow.cpp @@ -98,6 +98,8 @@ static constexpr char DISC_IMAGE_FILTER[] = QT_TRANSLATE_NOOP( "*.PBP);;PlayStation Executables (*.cpe *.elf *.exe *.psexe *.ps-exe, *.psx);;Portable Sound Format Files (*.psf " "*.minipsf);;Playlists (*.m3u);;PSX GPU Dumps (*.psxgpu *.psxgpu.zst *.psxgpu.xz)"); +static constexpr char IMAGE_FILTER[] = QT_TRANSLATE_NOOP("MainWindow", "Images (*.jpg *.jpeg *.png *.webp)"); + MainWindow* g_main_window = nullptr; // UI thread VM validity. @@ -1556,8 +1558,8 @@ void MainWindow::onGameListEntryContextMenuRequested(const QPoint& point) void MainWindow::setGameListEntryCoverImage(const GameList::Entry* entry) { - const QString filename = QDir::toNativeSeparators(QFileDialog::getOpenFileName( - this, tr("Select Cover Image"), QString(), tr("All Cover Image Types (*.jpg *.jpeg *.png *.webp)"))); + const QString filename = + QDir::toNativeSeparators(QFileDialog::getOpenFileName(this, tr("Select Cover Image"), QString(), tr(IMAGE_FILTER))); if (filename.isEmpty()) return; @@ -2130,6 +2132,10 @@ void MainWindow::connectSignals() connect(m_ui.actionGridViewZoomOut, &QAction::triggered, this, &MainWindow::onViewGameGridZoomOutActionTriggered); connect(m_ui.actionGridViewRefreshCovers, &QAction::triggered, m_game_list_widget, &GameListWidget::refreshGridCovers); + connect(m_ui.actionChangeGameListBackground, &QAction::triggered, this, + &MainWindow::onViewChangeGameListBackgroundTriggered); + connect(m_ui.actionClearGameListBackground, &QAction::triggered, this, + &MainWindow::onViewClearGameListBackgroundTriggered); connect(g_emu_thread, &EmuThread::settingsResetToDefault, this, &MainWindow::onSettingsResetToDefault, Qt::QueuedConnection); @@ -2414,6 +2420,26 @@ void MainWindow::doControllerSettings( dlg->setCategory(category); } +void MainWindow::onViewChangeGameListBackgroundTriggered() +{ + const QString path = QDir::toNativeSeparators( + QFileDialog::getOpenFileName(this, tr("Select Background Image"), QString(), tr(IMAGE_FILTER))); + if (path.isEmpty()) + return; + + std::string relative_path = Path::MakeRelative(QDir::toNativeSeparators(path).toStdString(), EmuFolders::DataRoot); + Host::SetBaseStringSettingValue("UI", "GameListBackgroundPath", relative_path.c_str()); + Host::CommitBaseSettingChanges(); + m_game_list_widget->updateBackground(true); +} + +void MainWindow::onViewClearGameListBackgroundTriggered() +{ + Host::DeleteBaseSettingValue("UI", "GameListBackgroundPath"); + Host::CommitBaseSettingChanges(); + m_game_list_widget->updateBackground(true); +} + void MainWindow::onSettingsTriggeredFromToolbar() { if (s_system_valid) diff --git a/src/duckstation-qt/mainwindow.h b/src/duckstation-qt/mainwindow.h index 987db0405..0a2f4f489 100644 --- a/src/duckstation-qt/mainwindow.h +++ b/src/duckstation-qt/mainwindow.h @@ -264,6 +264,8 @@ private: void doSettings(const char* category = nullptr); void openGamePropertiesForCurrentGame(const char* category = nullptr); void doControllerSettings(ControllerSettingsWindow::Category category = ControllerSettingsWindow::Category::Count); + void onViewChangeGameListBackgroundTriggered(); + void onViewClearGameListBackgroundTriggered(); std::string getDeviceDiscPath(const QString& title); void setGameListEntryCoverImage(const GameList::Entry* entry); diff --git a/src/duckstation-qt/mainwindow.ui b/src/duckstation-qt/mainwindow.ui index f116e55a8..49e7e4014 100644 --- a/src/duckstation-qt/mainwindow.ui +++ b/src/duckstation-qt/mainwindow.ui @@ -219,6 +219,9 @@ + + + @@ -980,6 +983,16 @@ Controller Presets + + + Change List Background... + + + + + Clear List Background + + diff --git a/src/duckstation-qt/qthost.cpp b/src/duckstation-qt/qthost.cpp index d3fccbf37..63bb57093 100644 --- a/src/duckstation-qt/qthost.cpp +++ b/src/duckstation-qt/qthost.cpp @@ -1428,6 +1428,27 @@ void QtHost::RunOnUIThread(const std::function& func, bool block /*= fal Q_ARG(const std::function&, func)); } +QtAsyncTask::QtAsyncTask(WorkCallback callback) +{ + m_callback = std::move(callback); +} + +QtAsyncTask::~QtAsyncTask() = default; + +void QtAsyncTask::create(QObject* owner, WorkCallback callback) +{ + // NOTE: Must get connected before queuing, because otherwise you risk a race. + QtAsyncTask* task = new QtAsyncTask(std::move(callback)); + connect(task, &QtAsyncTask::completed, owner, [task]() { std::get(task->m_callback)(); }); + System::QueueAsyncTask([task]() { + task->m_callback = std::get(task->m_callback)(); + QtHost::RunOnUIThread([task]() { + emit task->completed(task); + delete task; + }); + }); +} + void Host::RefreshGameListAsync(bool invalidate_cache) { QMetaObject::invokeMethod(g_main_window, "refreshGameList", Qt::QueuedConnection, Q_ARG(bool, invalidate_cache)); diff --git a/src/duckstation-qt/qthost.h b/src/duckstation-qt/qthost.h index 1f661150f..a4ba8ecec 100644 --- a/src/duckstation-qt/qthost.h +++ b/src/duckstation-qt/qthost.h @@ -33,6 +33,7 @@ #include #include #include +#include class QActionGroup; class QEventLoop; @@ -306,6 +307,27 @@ private: QStringList m_vibration_motors; }; +class QtAsyncTask : public QObject +{ + Q_OBJECT + +public: + using CompletionCallback = std::function; + using WorkCallback = std::function; + + ~QtAsyncTask(); + + static void create(QObject* owner, WorkCallback callback); + +Q_SIGNALS: + void completed(QtAsyncTask* self); + +private: + QtAsyncTask(WorkCallback callback); + + std::variant m_callback; +}; + extern EmuThread* g_emu_thread; namespace QtHost {