Qt: Add game list background function

This commit is contained in:
Stenzek 2025-03-23 15:58:56 +10:00
parent 98d1c71981
commit 9020959511
No known key found for this signature in database
7 changed files with 205 additions and 107 deletions

View File

@ -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<int>(static_cast<qreal>(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<float>(image->width()) / static_cast<float>(image->height())) >=
(static_cast<float>(dpr_expected_width) / static_cast<float>(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<int>(static_cast<qreal>((dpr_expected_width - image->width()) / 2) / dpr);
if (image->height() < dpr_expected_height)
yoffs = static_cast<int>(static_cast<qreal>((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<int>(static_cast<qreal>((dpr_expected_width - image_width) / 2) / dpr);
if (image_height < dpr_expected_height)
yoffs = static_cast<int>(static_cast<qreal>((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<int>(32.0f * m_scale), 1));
painter.setFont(font);
painter.setPen(Qt::white);
const QRect text_rc(0, 0, static_cast<int>(static_cast<float>(m_width)),
static_cast<int>(static_cast<float>(m_height)));
painter.drawText(text_rc, Qt::AlignCenter | Qt::TextWordWrap, QString::fromStdString(m_title));
painter.end();
}
}
std::optional<GameListModel::Column> 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<int>(32.0f * scale), 1));
painter.setFont(font);
painter.setPen(Qt::white);
const QRect text_rc(0, 0, static_cast<int>(static_cast<float>(width)),
static_cast<int>(static_cast<float>(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()

View File

@ -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<int>& 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<std::string, QPixmap> 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;
};

View File

@ -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)

View File

@ -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);

View File

@ -219,6 +219,9 @@
<addaction name="actionGridViewZoomIn"/>
<addaction name="actionGridViewZoomOut"/>
<addaction name="actionGridViewRefreshCovers"/>
<addaction name="separator"/>
<addaction name="actionChangeGameListBackground"/>
<addaction name="actionClearGameListBackground"/>
</widget>
<widget class="QMenu" name="menu_Tools">
<property name="title">
@ -980,6 +983,16 @@
<string>Controller Presets</string>
</property>
</action>
<action name="actionChangeGameListBackground">
<property name="text">
<string>Change List Background...</string>
</property>
</action>
<action name="actionClearGameListBackground">
<property name="text">
<string>Clear List Background</string>
</property>
</action>
</widget>
<resources>
<include location="resources/duckstation-qt.qrc"/>

View File

@ -1428,6 +1428,27 @@ void QtHost::RunOnUIThread(const std::function<void()>& func, bool block /*= fal
Q_ARG(const std::function<void()>&, 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<CompletionCallback>(task->m_callback)(); });
System::QueueAsyncTask([task]() {
task->m_callback = std::get<WorkCallback>(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));

View File

@ -33,6 +33,7 @@
#include <string>
#include <utility>
#include <vector>
#include <variant>
class QActionGroup;
class QEventLoop;
@ -306,6 +307,27 @@ private:
QStringList m_vibration_motors;
};
class QtAsyncTask : public QObject
{
Q_OBJECT
public:
using CompletionCallback = std::function<void()>;
using WorkCallback = std::function<CompletionCallback()>;
~QtAsyncTask();
static void create(QObject* owner, WorkCallback callback);
Q_SIGNALS:
void completed(QtAsyncTask* self);
private:
QtAsyncTask(WorkCallback callback);
std::variant<WorkCallback, CompletionCallback> m_callback;
};
extern EmuThread* g_emu_thread;
namespace QtHost {