From a0fef2d5abc50d6306eeff20725c9c3ff9d4afdd Mon Sep 17 00:00:00 2001 From: Stenzek Date: Sat, 1 Mar 2025 18:13:36 +1000 Subject: [PATCH] Qt: Merge gamelistmodel.cpp and gamelistwidget.cpp They're constantly referring to each other. --- src/duckstation-qt/CMakeLists.txt | 2 - src/duckstation-qt/duckstation-qt.vcxproj | 3 - .../duckstation-qt.vcxproj.filters | 5 - src/duckstation-qt/gamelistmodel.cpp | 896 ------------------ src/duckstation-qt/gamelistmodel.h | 165 ---- src/duckstation-qt/gamelistwidget.cpp | 884 ++++++++++++++++- src/duckstation-qt/gamelistwidget.h | 159 ++++ src/duckstation-qt/mainwindow.cpp | 1 - 8 files changed, 1042 insertions(+), 1073 deletions(-) delete mode 100644 src/duckstation-qt/gamelistmodel.cpp delete mode 100644 src/duckstation-qt/gamelistmodel.h diff --git a/src/duckstation-qt/CMakeLists.txt b/src/duckstation-qt/CMakeLists.txt index 73e3e4686..cb3d3c3de 100644 --- a/src/duckstation-qt/CMakeLists.txt +++ b/src/duckstation-qt/CMakeLists.txt @@ -80,8 +80,6 @@ set(SRCS gamepatchsettingswidget.cpp gamepatchsettingswidget.h gamepatchsettingswidget.ui - gamelistmodel.cpp - gamelistmodel.h gamelistrefreshthread.cpp gamelistrefreshthread.h gamelistsettingswidget.cpp diff --git a/src/duckstation-qt/duckstation-qt.vcxproj b/src/duckstation-qt/duckstation-qt.vcxproj index 9a4e242a0..2c153359b 100644 --- a/src/duckstation-qt/duckstation-qt.vcxproj +++ b/src/duckstation-qt/duckstation-qt.vcxproj @@ -20,7 +20,6 @@ - @@ -69,7 +68,6 @@ - @@ -231,7 +229,6 @@ - diff --git a/src/duckstation-qt/duckstation-qt.vcxproj.filters b/src/duckstation-qt/duckstation-qt.vcxproj.filters index 8fac7166a..2e836a206 100644 --- a/src/duckstation-qt/duckstation-qt.vcxproj.filters +++ b/src/duckstation-qt/duckstation-qt.vcxproj.filters @@ -18,7 +18,6 @@ - @@ -96,9 +95,6 @@ moc - - moc - moc @@ -215,7 +211,6 @@ - diff --git a/src/duckstation-qt/gamelistmodel.cpp b/src/duckstation-qt/gamelistmodel.cpp deleted file mode 100644 index 96fcfef36..000000000 --- a/src/duckstation-qt/gamelistmodel.cpp +++ /dev/null @@ -1,896 +0,0 @@ -// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin -// SPDX-License-Identifier: CC-BY-NC-ND-4.0 - -#include "gamelistmodel.h" -#include "qthost.h" -#include "qtutils.h" - -#include "core/system.h" - -#include "common/assert.h" -#include "common/file_system.h" -#include "common/path.h" -#include "common/string_util.h" - -#include -#include -#include -#include -#include - -static constexpr std::array s_column_names = { - {"Icon", "Serial", "Title", "File Title", "Developer", "Publisher", "Genre", "Year", "Players", "Time Played", - "Last Played", "Size", "File Size", "Region", "Achievements", "Compatibility", "Cover"}}; - -static constexpr int COVER_ART_WIDTH = 512; -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) -{ - const qreal dpr = image->devicePixelRatio(); - const int dpr_expected_width = static_cast(static_cast(expected_width) * dpr); - const int dpr_expected_height = static_cast(static_cast(expected_height) * dpr); - if (image->width() == dpr_expected_width && image->height() == dpr_expected_height) - return; - - if (image->width() > image->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; - - // 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); - - QImage padded_image(dpr_expected_width, dpr_expected_height, QImage::Format_ARGB32); - padded_image.setDevicePixelRatio(dpr); - padded_image.fill(Qt::transparent); - QPainter painter; - if (painter.begin(&padded_image)) - { - painter.setCompositionMode(QPainter::CompositionMode_Source); - painter.drawImage(xoffs, yoffs, *image); - painter.end(); - } - - *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++) - { - if (name == s_column_names[column]) - return static_cast(column); - } - - return std::nullopt; -} - -const char* GameListModel::getColumnName(Column col) -{ - return s_column_names[static_cast(col)]; -} - -GameListModel::GameListModel(float cover_scale, bool show_cover_titles, bool show_game_icons, - QObject* parent /* = nullptr */) - : QAbstractTableModel(parent), m_show_titles_for_covers(show_cover_titles), m_show_game_icons(show_game_icons), - m_memcard_pixmap_cache(MIN_COVER_CACHE_SIZE) -{ - loadCommonImages(); - setCoverScale(cover_scale); - setColumnDisplayNames(); - - if (m_show_game_icons) - GameList::ReloadMemcardTimestampCache(); - - connect(g_emu_thread, &EmuThread::gameListRowsChanged, this, &GameListModel::rowsChanged); -} - -GameListModel::~GameListModel() = default; - -void GameListModel::setShowGameIcons(bool enabled) -{ - m_show_game_icons = enabled; - - beginResetModel(); - m_memcard_pixmap_cache.Clear(); - if (enabled) - GameList::ReloadMemcardTimestampCache(); - endResetModel(); -} - -void GameListModel::setCoverScale(float scale) -{ - if (m_cover_scale == scale) - return; - - m_cover_pixmap_cache.Clear(); - m_cover_scale = scale; - - const qreal dpr = qApp->devicePixelRatio(); - - QImage loading_image; - if (loading_image.load(QStringLiteral("%1/images/placeholder.png").arg(QtHost::GetResourcesBasePath()))) - { - loading_image.setDevicePixelRatio(dpr); - resizeAndPadImage(&loading_image, getCoverArtWidth(), getCoverArtHeight()); - } - else - { - loading_image = QImage(getCoverArtWidth(), getCoverArtHeight(), QImage::Format_RGB32); - loading_image.setDevicePixelRatio(dpr); - loading_image.fill(QColor(0, 0, 0, 0)); - } - m_loading_pixmap = QPixmap::fromImage(loading_image); - - m_placeholder_image = QImage(); - 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()); - } - else - { - m_placeholder_image = QImage(getCoverArtWidth(), getCoverArtHeight(), QImage::Format_RGB32); - m_placeholder_image.setDevicePixelRatio(dpr); - m_placeholder_image.fill(QColor(0, 0, 0, 0)); - } - - emit coverScaleChanged(); -} - -void GameListModel::refreshCovers() -{ - m_cover_pixmap_cache.Clear(); - refresh(); -} - -void GameListModel::updateCacheSize(int width, int height) -{ - // This is a bit conversative, since it doesn't consider padding, but better to be over than under. - const int cover_width = getCoverArtWidth(); - const int cover_height = getCoverArtHeight(); - const int num_columns = ((width + (cover_width - 1)) / cover_width); - const int num_rows = ((height + (cover_height - 1)) / cover_height); - m_cover_pixmap_cache.SetMaxCapacity(static_cast(std::max(num_columns * num_rows, MIN_COVER_CACHE_SIZE))); -} - -void GameListModel::reloadThemeSpecificImages() -{ - loadThemeSpecificImages(); - refresh(); -} - -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(); }); -} - -void GameListModel::coverLoaded(const std::string& path, const QImage& image, float scale) -{ - // old request before cover scale change? - if (m_cover_scale != scale) - return; - - if (!image.isNull()) - m_cover_pixmap_cache.Insert(path, QPixmap::fromImage(image)); - else - m_cover_pixmap_cache.Insert(path, QPixmap()); - - invalidateCoverForPath(path); -} - -void GameListModel::rowsChanged(const QList& rows) -{ - const QList roles_changed = {Qt::DisplayRole}; - - // try to collapse multiples - size_t start = 0; - size_t idx = 0; - const size_t size = rows.size(); - for (; idx < size;) - { - if ((idx + 1) < size && rows[idx + 1] == (rows[idx] + 1)) - { - idx++; - } - else - { - emit dataChanged(createIndex(rows[start], 0), createIndex(rows[idx], Column_Count - 1), roles_changed); - start = ++idx; - } - } -} - -void GameListModel::invalidateCoverForPath(const std::string& path) -{ - std::optional row; - if (hasTakenGameList()) - { - for (u32 i = 0; i < static_cast(m_taken_entries->size()); i++) - { - if (path == m_taken_entries.value()[i].path) - { - row = i; - break; - } - } - } - else - { - // This isn't ideal, but not sure how else we can get the row, when it might change while scanning... - auto lock = GameList::GetLock(); - const u32 count = GameList::GetEntryCount(); - for (u32 i = 0; i < count; i++) - { - if (GameList::GetEntryByIndex(i)->path == path) - { - row = i; - break; - } - } - } - - if (!row.has_value()) - { - // Game removed? - return; - } - - const QModelIndex mi(index(static_cast(row.value()), Column_Cover)); - emit dataChanged(mi, mi, {Qt::DecorationRole}); -} - -QString GameListModel::formatTimespan(time_t timespan) -{ - // avoid an extra string conversion - const u32 hours = static_cast(timespan / 3600); - const u32 minutes = static_cast((timespan % 3600) / 60); - if (hours > 0) - return qApp->translate("GameList", "%n hours", "", hours); - else - return qApp->translate("GameList", "%n minutes", "", minutes); -} - -const QPixmap& GameListModel::getIconPixmapForEntry(const GameList::Entry* ge) const -{ - // We only do this for discs/disc sets for now. - if (m_show_game_icons && (!ge->serial.empty() && (ge->IsDisc() || ge->IsDiscSet()))) - { - QPixmap* item = m_memcard_pixmap_cache.Lookup(ge->serial); - if (item) - return *item; - - // Assumes game list lock is held. - const std::string path = GameList::GetGameIconPath(ge->serial, ge->path); - QPixmap pm; - if (!path.empty() && pm.load(QString::fromStdString(path))) - { - fixIconPixmapSize(pm); - return *m_memcard_pixmap_cache.Insert(ge->serial, std::move(pm)); - } - - return *m_memcard_pixmap_cache.Insert(ge->serial, m_type_pixmaps[static_cast(ge->type)]); - } - - return m_type_pixmaps[static_cast(ge->type)]; -} - -const QPixmap& GameListModel::getFlagPixmapForEntry(const GameList::Entry* ge) const -{ - static constexpr u32 FLAG_PIXMAP_WIDTH = 30; - static constexpr u32 FLAG_PIXMAP_HEIGHT = 20; - - const std::string_view name = ge->GetLanguageIcon(); - auto it = m_flag_pixmap_cache.find(name); - if (it != m_flag_pixmap_cache.end()) - return it->second; - - const QIcon icon(QString::fromStdString(QtHost::GetResourcePath(ge->GetLanguageIconName(), true))); - it = m_flag_pixmap_cache.emplace(name, icon.pixmap(FLAG_PIXMAP_WIDTH, FLAG_PIXMAP_HEIGHT)).first; - return it->second; -} - -QIcon GameListModel::getIconForGame(const QString& path) -{ - QIcon ret; - - if (m_show_game_icons) - { - const auto lock = GameList::GetLock(); - const GameList::Entry* entry = GameList::GetEntryForPath(path.toStdString()); - - // See above. - if (entry && !entry->serial.empty() && (entry->IsDisc() || entry->IsDiscSet())) - { - const std::string icon_path = GameList::GetGameIconPath(entry->serial, entry->path); - if (!icon_path.empty()) - { - QPixmap newpm; - if (!icon_path.empty() && newpm.load(QString::fromStdString(icon_path))) - { - fixIconPixmapSize(newpm); - ret = QIcon(*m_memcard_pixmap_cache.Insert(entry->serial, std::move(newpm))); - } - } - } - } - - return ret; -} - -void GameListModel::fixIconPixmapSize(QPixmap& pm) -{ - const qreal dpr = pm.devicePixelRatio(); - const int width = static_cast(static_cast(pm.width()) * dpr); - const int height = static_cast(static_cast(pm.height()) * dpr); - const int max_dim = std::max(width, height); - if (max_dim == 16) - return; - - const float wanted_dpr = qApp->devicePixelRatio(); - pm.setDevicePixelRatio(wanted_dpr); - - const float scale = static_cast(max_dim) / 16.0f / wanted_dpr; - const int new_width = static_cast(static_cast(width) / scale); - const int new_height = static_cast(static_cast(height) / scale); - pm = pm.scaled(new_width, new_height); -} - -int GameListModel::getCoverArtWidth() const -{ - return std::max(static_cast(static_cast(COVER_ART_WIDTH) * m_cover_scale), 1); -} - -int GameListModel::getCoverArtHeight() const -{ - return std::max(static_cast(static_cast(COVER_ART_HEIGHT) * m_cover_scale), 1); -} - -int GameListModel::getCoverArtSpacing() const -{ - return std::max(static_cast(static_cast(COVER_ART_SPACING) * m_cover_scale), 1); -} - -int GameListModel::rowCount(const QModelIndex& parent) const -{ - if (parent.isValid()) [[unlikely]] - return 0; - - if (m_taken_entries.has_value()) - return static_cast(m_taken_entries->size()); - - const auto lock = GameList::GetLock(); - return static_cast(GameList::GetEntryCount()); -} - -int GameListModel::columnCount(const QModelIndex& parent) const -{ - if (parent.isValid()) - return 0; - - return Column_Count; -} - -QVariant GameListModel::data(const QModelIndex& index, int role) const -{ - if (!index.isValid()) [[unlikely]] - return {}; - - const int row = index.row(); - DebugAssert(row >= 0); - - if (m_taken_entries.has_value()) [[unlikely]] - { - if (static_cast(row) >= m_taken_entries->size()) - return {}; - - return data(index, role, &m_taken_entries.value()[row]); - } - else - { - const auto lock = GameList::GetLock(); - const GameList::Entry* ge = GameList::GetEntryByIndex(static_cast(row)); - if (!ge) - return {}; - - return data(index, role, ge); - } -} - -QVariant GameListModel::data(const QModelIndex& index, int role, const GameList::Entry* ge) const -{ - switch (role) - { - case Qt::DisplayRole: - { - switch (index.column()) - { - case Column_Serial: - return QtUtils::StringViewToQString(ge->serial); - - case Column_Title: - return QtUtils::StringViewToQString(ge->title); - - case Column_FileTitle: - return QtUtils::StringViewToQString(Path::GetFileTitle(ge->path)); - - case Column_Developer: - return ge->dbentry ? QtUtils::StringViewToQString(ge->dbentry->developer) : QString(); - - case Column_Publisher: - return ge->dbentry ? QtUtils::StringViewToQString(ge->dbentry->publisher) : QString(); - - case Column_Genre: - return ge->dbentry ? QtUtils::StringViewToQString(ge->dbentry->genre) : QString(); - - case Column_Year: - { - if (ge->dbentry && ge->dbentry->release_date != 0) - { - return QStringLiteral("%1").arg( - QDateTime::fromSecsSinceEpoch(static_cast(ge->dbentry->release_date), QTimeZone::utc()) - .date() - .year()); - } - else - { - return QString(); - } - } - - case Column_Players: - { - if (!ge->dbentry || ge->dbentry->min_players == 0) - return QString(); - else if (ge->dbentry->min_players == ge->dbentry->max_players) - return QStringLiteral("%1").arg(ge->dbentry->min_players); - else - return QStringLiteral("%1-%2").arg(ge->dbentry->min_players).arg(ge->dbentry->max_players); - } - - case Column_FileSize: - return (ge->file_size >= 0) ? - QStringLiteral("%1 MB").arg(static_cast(ge->file_size) / 1048576.0, 0, 'f', 2) : - tr("Unknown"); - - case Column_UncompressedSize: - return QStringLiteral("%1 MB").arg(static_cast(ge->uncompressed_size) / 1048576.0, 0, 'f', 2); - - case Column_Achievements: - return {}; - - case Column_TimePlayed: - { - if (ge->total_played_time == 0) - return {}; - else - return formatTimespan(ge->total_played_time); - } - - case Column_LastPlayed: - return QtUtils::StringViewToQString(GameList::FormatTimestamp(ge->last_played_time)); - - case Column_Cover: - { - if (m_show_titles_for_covers) - return QString::fromStdString(ge->title); - else - return {}; - } - - default: - return {}; - } - } - - case Qt::InitialSortOrderRole: - { - const int column = index.column(); - if (column == Column_TimePlayed || column == Column_LastPlayed) - return Qt::DescendingOrder; - else - return Qt::AscendingOrder; - } - - case Qt::DecorationRole: - { - switch (index.column()) - { - case Column_Icon: - { - return getIconPixmapForEntry(ge); - } - - case Column_Region: - { - return getFlagPixmapForEntry(ge); - } - - case Column_Compatibility: - { - return m_compatibility_pixmaps[static_cast(ge->dbentry ? ge->dbentry->compatibility : - GameDatabase::CompatibilityRating::Unknown)]; - } - - case Column_Cover: - { - QPixmap* pm = m_cover_pixmap_cache.Lookup(ge->path); - if (pm) - return *pm; - - // We insert the placeholder into the cache, so that we don't repeatedly - // queue loading jobs for this game. - const_cast(this)->loadOrGenerateCover(ge); - return *m_cover_pixmap_cache.Insert(ge->path, m_loading_pixmap); - } - break; - - default: - return {}; - } - - default: - return {}; - } - } -} - -QVariant GameListModel::headerData(int section, Qt::Orientation orientation, int role) const -{ - if (orientation != Qt::Horizontal || role != Qt::DisplayRole || section < 0 || section >= Column_Count) - return {}; - - return m_column_display_names[section]; -} - -const GameList::Entry* GameListModel::getTakenGameListEntry(u32 index) const -{ - return (m_taken_entries.has_value() && index < m_taken_entries->size()) ? &m_taken_entries.value()[index] : nullptr; -} - -bool GameListModel::hasTakenGameList() const -{ - return m_taken_entries.has_value(); -} - -void GameListModel::takeGameList() -{ - const auto lock = GameList::GetLock(); - m_taken_entries = GameList::TakeEntryList(); - - // If it's empty (e.g. first boot), don't use it. - if (m_taken_entries->empty()) - m_taken_entries.reset(); -} - -void GameListModel::refresh() -{ - beginResetModel(); - - m_taken_entries.reset(); - - // Invalidate memcard LRU cache, forcing a re-query of the memcard timestamps. - m_memcard_pixmap_cache.Clear(); - - endResetModel(); -} - -bool GameListModel::titlesLessThan(const GameList::Entry* left, const GameList::Entry* right) const -{ - return (StringUtil::Strcasecmp(left->title.c_str(), right->title.c_str()) < 0); -} - -bool GameListModel::lessThan(const QModelIndex& left_index, const QModelIndex& right_index, int column) const -{ - if (!left_index.isValid() || !right_index.isValid()) - return false; - - const int left_row = left_index.row(); - const int right_row = right_index.row(); - - if (m_taken_entries.has_value()) [[unlikely]] - { - const GameList::Entry* left = - (static_cast(left_row) < m_taken_entries->size()) ? &m_taken_entries.value()[left_row] : nullptr; - const GameList::Entry* right = - (static_cast(right_row) < m_taken_entries->size()) ? &m_taken_entries.value()[right_row] : nullptr; - if (!left || !right) - return false; - - return lessThan(left, right, column); - } - else - { - const auto lock = GameList::GetLock(); - const GameList::Entry* left = GameList::GetEntryByIndex(left_row); - const GameList::Entry* right = GameList::GetEntryByIndex(right_row); - if (!left || !right) - return false; - - return lessThan(left, right, column); - } -} - -bool GameListModel::lessThan(const GameList::Entry* left, const GameList::Entry* right, int column) const -{ - switch (column) - { - case Column_Icon: - { - const GameList::EntryType lst = left->GetSortType(); - const GameList::EntryType rst = right->GetSortType(); - if (lst == rst) - return titlesLessThan(left, right); - - return (static_cast(lst) < static_cast(rst)); - } - - case Column_Serial: - { - if (left->serial == right->serial) - return titlesLessThan(left, right); - return (StringUtil::Strcasecmp(left->serial.c_str(), right->serial.c_str()) < 0); - } - - case Column_Title: - { - return titlesLessThan(left, right); - } - - case Column_FileTitle: - { - const std::string_view file_title_left = Path::GetFileTitle(left->path); - const std::string_view file_title_right = Path::GetFileTitle(right->path); - if (file_title_left == file_title_right) - return titlesLessThan(left, right); - - const std::size_t smallest = std::min(file_title_left.size(), file_title_right.size()); - return (StringUtil::Strncasecmp(file_title_left.data(), file_title_right.data(), smallest) < 0); - } - - case Column_Region: - { - if (left->region == right->region) - return titlesLessThan(left, right); - return (static_cast(left->region) < static_cast(right->region)); - } - - case Column_Compatibility: - { - const GameDatabase::CompatibilityRating left_compatibility = - left->dbentry ? left->dbentry->compatibility : GameDatabase::CompatibilityRating::Unknown; - const GameDatabase::CompatibilityRating right_compatibility = - right->dbentry ? right->dbentry->compatibility : GameDatabase::CompatibilityRating::Unknown; - if (left_compatibility == right_compatibility) - return titlesLessThan(left, right); - - return (static_cast(left_compatibility) < static_cast(right_compatibility)); - } - - case Column_FileSize: - { - if (left->file_size == right->file_size) - return titlesLessThan(left, right); - - return (left->file_size < right->file_size); - } - - case Column_UncompressedSize: - { - if (left->uncompressed_size == right->uncompressed_size) - return titlesLessThan(left, right); - - return (left->uncompressed_size < right->uncompressed_size); - } - - case Column_Genre: - { - const int compres = - StringUtil::CompareNoCase(left->dbentry ? std::string_view(left->dbentry->genre) : std::string_view(), - right->dbentry ? std::string_view(right->dbentry->genre) : std::string_view()); - return (compres == 0) ? titlesLessThan(left, right) : (compres < 0); - } - - case Column_Developer: - { - const int compres = - StringUtil::CompareNoCase(left->dbentry ? std::string_view(left->dbentry->developer) : std::string_view(), - right->dbentry ? std::string_view(right->dbentry->developer) : std::string_view()); - return (compres == 0) ? titlesLessThan(left, right) : (compres < 0); - } - - case Column_Publisher: - { - const int compres = - StringUtil::CompareNoCase(left->dbentry ? std::string_view(left->dbentry->publisher) : std::string_view(), - right->dbentry ? std::string_view(right->dbentry->publisher) : std::string_view()); - return (compres == 0) ? titlesLessThan(left, right) : (compres < 0); - } - - case Column_Year: - { - const u64 ldate = left->dbentry ? left->dbentry->release_date : 0; - const u64 rdate = right->dbentry ? right->dbentry->release_date : 0; - if (ldate == rdate) - return titlesLessThan(left, right); - - return (ldate < rdate); - } - - case Column_TimePlayed: - { - if (left->total_played_time == right->total_played_time) - return titlesLessThan(left, right); - - return (left->total_played_time < right->total_played_time); - } - - case Column_LastPlayed: - { - if (left->last_played_time == right->last_played_time) - return titlesLessThan(left, right); - - return (left->last_played_time < right->last_played_time); - } - - case Column_Players: - { - const u8 left_players = left->dbentry ? ((left->dbentry->min_players << 4) + left->dbentry->max_players) : 0; - const u8 right_players = right->dbentry ? ((right->dbentry->min_players << 4) + right->dbentry->max_players) : 0; - if (left_players == right_players) - return titlesLessThan(left, right); - - return (left_players < right_players); - } - - case Column_Achievements: - { - // sort by unlock percentage - const float unlock_left = - (left->num_achievements > 0) ? - (static_cast(std::max(left->unlocked_achievements, left->unlocked_achievements_hc)) / - static_cast(left->num_achievements)) : - 0; - const float unlock_right = - (right->num_achievements > 0) ? - (static_cast(std::max(right->unlocked_achievements, right->unlocked_achievements_hc)) / - static_cast(right->num_achievements)) : - 0; - if (std::abs(unlock_left - unlock_right) < 0.0001f) - { - // order by achievement count - if (left->num_achievements == right->num_achievements) - return titlesLessThan(left, right); - - return (left->num_achievements < right->num_achievements); - } - - return (unlock_left < unlock_right); - } - - default: - return false; - } -} - -void GameListModel::loadThemeSpecificImages() -{ - for (u32 i = 0; i < static_cast(GameList::EntryType::Count); i++) - m_type_pixmaps[i] = QtUtils::GetIconForEntryType(static_cast(i)).pixmap(QSize(24, 24)); -} - -void GameListModel::loadCommonImages() -{ - loadThemeSpecificImages(); - - for (int i = 0; i < static_cast(GameDatabase::CompatibilityRating::Count); i++) - { - m_compatibility_pixmaps[i] = - QtUtils::GetIconForCompatibility(static_cast(i)).pixmap(96, 24); - } - - constexpr int ACHIEVEMENT_ICON_SIZE = 16; - m_no_achievements_pixmap = QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon-gray.svg", true))) - .pixmap(ACHIEVEMENT_ICON_SIZE); - m_has_achievements_pixmap = QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon.svg", true))) - .pixmap(ACHIEVEMENT_ICON_SIZE); - m_mastered_achievements_pixmap = - QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon-star.svg", true))) - .pixmap(ACHIEVEMENT_ICON_SIZE); -} - -void GameListModel::setColumnDisplayNames() -{ - m_column_display_names[Column_Icon] = tr("Icon"); - m_column_display_names[Column_Serial] = tr("Serial"); - m_column_display_names[Column_Title] = tr("Title"); - m_column_display_names[Column_FileTitle] = tr("File Title"); - m_column_display_names[Column_Developer] = tr("Developer"); - m_column_display_names[Column_Publisher] = tr("Publisher"); - m_column_display_names[Column_Genre] = tr("Genre"); - m_column_display_names[Column_Year] = tr("Year"); - m_column_display_names[Column_Players] = tr("Players"); - m_column_display_names[Column_Achievements] = tr("Achievements"); - m_column_display_names[Column_TimePlayed] = tr("Time Played"); - m_column_display_names[Column_LastPlayed] = tr("Last Played"); - m_column_display_names[Column_FileSize] = tr("Size"); - m_column_display_names[Column_UncompressedSize] = tr("Raw Size"); - m_column_display_names[Column_Region] = tr("Region"); - m_column_display_names[Column_Compatibility] = tr("Compatibility"); -} diff --git a/src/duckstation-qt/gamelistmodel.h b/src/duckstation-qt/gamelistmodel.h deleted file mode 100644 index 52d8bb591..000000000 --- a/src/duckstation-qt/gamelistmodel.h +++ /dev/null @@ -1,165 +0,0 @@ -// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin -// SPDX-License-Identifier: CC-BY-NC-ND-4.0 - -#pragma once - -#include "core/game_database.h" -#include "core/game_list.h" -#include "core/types.h" - -#include "common/heterogeneous_containers.h" -#include "common/lru_cache.h" - -#include -#include -#include - -#include -#include -#include - -class GameListModel final : public QAbstractTableModel -{ - Q_OBJECT - -public: - enum Column : int - { - Column_Icon, - Column_Serial, - Column_Title, - Column_FileTitle, - Column_Developer, - Column_Publisher, - Column_Genre, - Column_Year, - Column_Players, - Column_TimePlayed, - Column_LastPlayed, - Column_FileSize, - Column_UncompressedSize, - Column_Region, - Column_Achievements, - Column_Compatibility, - Column_Cover, - - Column_Count - }; - - static std::optional getColumnIdForName(std::string_view name); - static const char* getColumnName(Column col); - - GameListModel(float cover_scale, bool show_cover_titles, bool show_game_icons, QObject* parent = nullptr); - ~GameListModel(); - - int rowCount(const QModelIndex& parent = QModelIndex()) const override; - int columnCount(const QModelIndex& parent = QModelIndex()) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - - ALWAYS_INLINE const QString& getColumnDisplayName(int column) const { return m_column_display_names[column]; } - ALWAYS_INLINE const QPixmap& getNoAchievementsPixmap() const { return m_no_achievements_pixmap; } - ALWAYS_INLINE const QPixmap& getHasAchievementsPixmap() const { return m_has_achievements_pixmap; } - ALWAYS_INLINE const QPixmap& getMasteredAchievementsPixmap() const { return m_mastered_achievements_pixmap; } - - const GameList::Entry* getTakenGameListEntry(u32 index) const; - bool hasTakenGameList() const; - void takeGameList(); - - void refresh(); - void reloadThemeSpecificImages(); - - bool titlesLessThan(const GameList::Entry* left, const GameList::Entry* right) const; - bool lessThan(const GameList::Entry* left, const GameList::Entry* right, int column) const; - - bool lessThan(const QModelIndex& left_index, const QModelIndex& right_index, int column) const; - - bool getShowCoverTitles() const { return m_show_titles_for_covers; } - void setShowCoverTitles(bool enabled) { m_show_titles_for_covers = enabled; } - - bool getShowGameIcons() const { return m_show_game_icons; } - void setShowGameIcons(bool enabled); - QIcon getIconForGame(const QString& path); - - float getCoverScale() const { return m_cover_scale; } - void setCoverScale(float scale); - int getCoverArtWidth() const; - int getCoverArtHeight() const; - int getCoverArtSpacing() const; - void refreshCovers(); - void updateCacheSize(int width, int height); - -Q_SIGNALS: - void coverScaleChanged(); - -private Q_SLOTS: - void coverLoaded(const std::string& path, const QImage& image, float scale); - void rowsChanged(const QList& rows); - -private: - QVariant data(const QModelIndex& index, int role, const GameList::Entry* ge) const; - - void loadCommonImages(); - void loadThemeSpecificImages(); - void setColumnDisplayNames(); - void loadOrGenerateCover(const GameList::Entry* ge); - void invalidateCoverForPath(const std::string& path); - - const QPixmap& getIconPixmapForEntry(const GameList::Entry* ge) const; - const QPixmap& getFlagPixmapForEntry(const GameList::Entry* ge) const; - static void fixIconPixmapSize(QPixmap& pm); - - static QString formatTimespan(time_t timespan); - - std::optional m_taken_entries; - - float m_cover_scale = 0.0f; - bool m_show_titles_for_covers = false; - bool m_show_game_icons = false; - - std::array m_column_display_names; - std::array(GameList::EntryType::Count)> m_type_pixmaps; - std::array(GameDatabase::CompatibilityRating::Count)> m_compatibility_pixmaps; - - QImage m_placeholder_image; - QPixmap m_loading_pixmap; - - QPixmap m_no_achievements_pixmap; - QPixmap m_has_achievements_pixmap; - QPixmap m_mastered_achievements_pixmap; - - mutable PreferUnorderedStringMap m_flag_pixmap_cache; - - mutable LRUCache m_cover_pixmap_cache; - - 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; -}; diff --git a/src/duckstation-qt/gamelistwidget.cpp b/src/duckstation-qt/gamelistwidget.cpp index 9680342b3..52f23cab2 100644 --- a/src/duckstation-qt/gamelistwidget.cpp +++ b/src/duckstation-qt/gamelistwidget.cpp @@ -2,7 +2,6 @@ // SPDX-License-Identifier: CC-BY-NC-ND-4.0 #include "gamelistwidget.h" -#include "gamelistmodel.h" #include "gamelistrefreshthread.h" #include "qthost.h" #include "qtutils.h" @@ -12,12 +11,18 @@ #include "core/game_list.h" #include "core/host.h" #include "core/settings.h" +#include "core/system.h" #include "common/assert.h" +#include "common/file_system.h" +#include "common/path.h" #include "common/string_util.h" +#include +#include #include #include +#include #include #include #include @@ -37,6 +42,883 @@ static const char* SUPPORTED_FORMATS_STRING = ".chd (Compressed Hunks of Data)\n" ".pbp (PlayStation Portable, Only Decrypted)"); +static constexpr std::array s_column_names = { + {"Icon", "Serial", "Title", "File Title", "Developer", "Publisher", "Genre", "Year", "Players", "Time Played", + "Last Played", "Size", "File Size", "Region", "Achievements", "Compatibility", "Cover"}}; + +static constexpr int COVER_ART_WIDTH = 512; +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) +{ + const qreal dpr = image->devicePixelRatio(); + const int dpr_expected_width = static_cast(static_cast(expected_width) * dpr); + const int dpr_expected_height = static_cast(static_cast(expected_height) * dpr); + if (image->width() == dpr_expected_width && image->height() == dpr_expected_height) + return; + + if (image->width() > image->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; + + // 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); + + QImage padded_image(dpr_expected_width, dpr_expected_height, QImage::Format_ARGB32); + padded_image.setDevicePixelRatio(dpr); + padded_image.fill(Qt::transparent); + QPainter painter; + if (painter.begin(&padded_image)) + { + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.drawImage(xoffs, yoffs, *image); + painter.end(); + } + + *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++) + { + if (name == s_column_names[column]) + return static_cast(column); + } + + return std::nullopt; +} + +const char* GameListModel::getColumnName(Column col) +{ + return s_column_names[static_cast(col)]; +} + +GameListModel::GameListModel(float cover_scale, bool show_cover_titles, bool show_game_icons, + QObject* parent /* = nullptr */) + : QAbstractTableModel(parent), m_show_titles_for_covers(show_cover_titles), m_show_game_icons(show_game_icons), + m_memcard_pixmap_cache(MIN_COVER_CACHE_SIZE) +{ + loadCommonImages(); + setCoverScale(cover_scale); + setColumnDisplayNames(); + + if (m_show_game_icons) + GameList::ReloadMemcardTimestampCache(); + + connect(g_emu_thread, &EmuThread::gameListRowsChanged, this, &GameListModel::rowsChanged); +} + +GameListModel::~GameListModel() = default; + +void GameListModel::setShowGameIcons(bool enabled) +{ + m_show_game_icons = enabled; + + beginResetModel(); + m_memcard_pixmap_cache.Clear(); + if (enabled) + GameList::ReloadMemcardTimestampCache(); + endResetModel(); +} + +void GameListModel::setCoverScale(float scale) +{ + if (m_cover_scale == scale) + return; + + m_cover_pixmap_cache.Clear(); + m_cover_scale = scale; + + const qreal dpr = qApp->devicePixelRatio(); + + QImage loading_image; + if (loading_image.load(QStringLiteral("%1/images/placeholder.png").arg(QtHost::GetResourcesBasePath()))) + { + loading_image.setDevicePixelRatio(dpr); + resizeAndPadImage(&loading_image, getCoverArtWidth(), getCoverArtHeight()); + } + else + { + loading_image = QImage(getCoverArtWidth(), getCoverArtHeight(), QImage::Format_RGB32); + loading_image.setDevicePixelRatio(dpr); + loading_image.fill(QColor(0, 0, 0, 0)); + } + m_loading_pixmap = QPixmap::fromImage(loading_image); + + m_placeholder_image = QImage(); + 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()); + } + else + { + m_placeholder_image = QImage(getCoverArtWidth(), getCoverArtHeight(), QImage::Format_RGB32); + m_placeholder_image.setDevicePixelRatio(dpr); + m_placeholder_image.fill(QColor(0, 0, 0, 0)); + } + + emit coverScaleChanged(); +} + +void GameListModel::refreshCovers() +{ + m_cover_pixmap_cache.Clear(); + refresh(); +} + +void GameListModel::updateCacheSize(int width, int height) +{ + // This is a bit conversative, since it doesn't consider padding, but better to be over than under. + const int cover_width = getCoverArtWidth(); + const int cover_height = getCoverArtHeight(); + const int num_columns = ((width + (cover_width - 1)) / cover_width); + const int num_rows = ((height + (cover_height - 1)) / cover_height); + m_cover_pixmap_cache.SetMaxCapacity(static_cast(std::max(num_columns * num_rows, MIN_COVER_CACHE_SIZE))); +} + +void GameListModel::reloadThemeSpecificImages() +{ + loadThemeSpecificImages(); + refresh(); +} + +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(); }); +} + +void GameListModel::coverLoaded(const std::string& path, const QImage& image, float scale) +{ + // old request before cover scale change? + if (m_cover_scale != scale) + return; + + if (!image.isNull()) + m_cover_pixmap_cache.Insert(path, QPixmap::fromImage(image)); + else + m_cover_pixmap_cache.Insert(path, QPixmap()); + + invalidateCoverForPath(path); +} + +void GameListModel::rowsChanged(const QList& rows) +{ + const QList roles_changed = {Qt::DisplayRole}; + + // try to collapse multiples + size_t start = 0; + size_t idx = 0; + const size_t size = rows.size(); + for (; idx < size;) + { + if ((idx + 1) < size && rows[idx + 1] == (rows[idx] + 1)) + { + idx++; + } + else + { + emit dataChanged(createIndex(rows[start], 0), createIndex(rows[idx], Column_Count - 1), roles_changed); + start = ++idx; + } + } +} + +void GameListModel::invalidateCoverForPath(const std::string& path) +{ + std::optional row; + if (hasTakenGameList()) + { + for (u32 i = 0; i < static_cast(m_taken_entries->size()); i++) + { + if (path == m_taken_entries.value()[i].path) + { + row = i; + break; + } + } + } + else + { + // This isn't ideal, but not sure how else we can get the row, when it might change while scanning... + auto lock = GameList::GetLock(); + const u32 count = GameList::GetEntryCount(); + for (u32 i = 0; i < count; i++) + { + if (GameList::GetEntryByIndex(i)->path == path) + { + row = i; + break; + } + } + } + + if (!row.has_value()) + { + // Game removed? + return; + } + + const QModelIndex mi(index(static_cast(row.value()), Column_Cover)); + emit dataChanged(mi, mi, {Qt::DecorationRole}); +} + +QString GameListModel::formatTimespan(time_t timespan) +{ + // avoid an extra string conversion + const u32 hours = static_cast(timespan / 3600); + const u32 minutes = static_cast((timespan % 3600) / 60); + if (hours > 0) + return qApp->translate("GameList", "%n hours", "", hours); + else + return qApp->translate("GameList", "%n minutes", "", minutes); +} + +const QPixmap& GameListModel::getIconPixmapForEntry(const GameList::Entry* ge) const +{ + // We only do this for discs/disc sets for now. + if (m_show_game_icons && (!ge->serial.empty() && (ge->IsDisc() || ge->IsDiscSet()))) + { + QPixmap* item = m_memcard_pixmap_cache.Lookup(ge->serial); + if (item) + return *item; + + // Assumes game list lock is held. + const std::string path = GameList::GetGameIconPath(ge->serial, ge->path); + QPixmap pm; + if (!path.empty() && pm.load(QString::fromStdString(path))) + { + fixIconPixmapSize(pm); + return *m_memcard_pixmap_cache.Insert(ge->serial, std::move(pm)); + } + + return *m_memcard_pixmap_cache.Insert(ge->serial, m_type_pixmaps[static_cast(ge->type)]); + } + + return m_type_pixmaps[static_cast(ge->type)]; +} + +const QPixmap& GameListModel::getFlagPixmapForEntry(const GameList::Entry* ge) const +{ + static constexpr u32 FLAG_PIXMAP_WIDTH = 30; + static constexpr u32 FLAG_PIXMAP_HEIGHT = 20; + + const std::string_view name = ge->GetLanguageIcon(); + auto it = m_flag_pixmap_cache.find(name); + if (it != m_flag_pixmap_cache.end()) + return it->second; + + const QIcon icon(QString::fromStdString(QtHost::GetResourcePath(ge->GetLanguageIconName(), true))); + it = m_flag_pixmap_cache.emplace(name, icon.pixmap(FLAG_PIXMAP_WIDTH, FLAG_PIXMAP_HEIGHT)).first; + return it->second; +} + +QIcon GameListModel::getIconForGame(const QString& path) +{ + QIcon ret; + + if (m_show_game_icons) + { + const auto lock = GameList::GetLock(); + const GameList::Entry* entry = GameList::GetEntryForPath(path.toStdString()); + + // See above. + if (entry && !entry->serial.empty() && (entry->IsDisc() || entry->IsDiscSet())) + { + const std::string icon_path = GameList::GetGameIconPath(entry->serial, entry->path); + if (!icon_path.empty()) + { + QPixmap newpm; + if (!icon_path.empty() && newpm.load(QString::fromStdString(icon_path))) + { + fixIconPixmapSize(newpm); + ret = QIcon(*m_memcard_pixmap_cache.Insert(entry->serial, std::move(newpm))); + } + } + } + } + + return ret; +} + +void GameListModel::fixIconPixmapSize(QPixmap& pm) +{ + const qreal dpr = pm.devicePixelRatio(); + const int width = static_cast(static_cast(pm.width()) * dpr); + const int height = static_cast(static_cast(pm.height()) * dpr); + const int max_dim = std::max(width, height); + if (max_dim == 16) + return; + + const float wanted_dpr = qApp->devicePixelRatio(); + pm.setDevicePixelRatio(wanted_dpr); + + const float scale = static_cast(max_dim) / 16.0f / wanted_dpr; + const int new_width = static_cast(static_cast(width) / scale); + const int new_height = static_cast(static_cast(height) / scale); + pm = pm.scaled(new_width, new_height); +} + +int GameListModel::getCoverArtWidth() const +{ + return std::max(static_cast(static_cast(COVER_ART_WIDTH) * m_cover_scale), 1); +} + +int GameListModel::getCoverArtHeight() const +{ + return std::max(static_cast(static_cast(COVER_ART_HEIGHT) * m_cover_scale), 1); +} + +int GameListModel::getCoverArtSpacing() const +{ + return std::max(static_cast(static_cast(COVER_ART_SPACING) * m_cover_scale), 1); +} + +int GameListModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) [[unlikely]] + return 0; + + if (m_taken_entries.has_value()) + return static_cast(m_taken_entries->size()); + + const auto lock = GameList::GetLock(); + return static_cast(GameList::GetEntryCount()); +} + +int GameListModel::columnCount(const QModelIndex& parent) const +{ + if (parent.isValid()) + return 0; + + return Column_Count; +} + +QVariant GameListModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) [[unlikely]] + return {}; + + const int row = index.row(); + DebugAssert(row >= 0); + + if (m_taken_entries.has_value()) [[unlikely]] + { + if (static_cast(row) >= m_taken_entries->size()) + return {}; + + return data(index, role, &m_taken_entries.value()[row]); + } + else + { + const auto lock = GameList::GetLock(); + const GameList::Entry* ge = GameList::GetEntryByIndex(static_cast(row)); + if (!ge) + return {}; + + return data(index, role, ge); + } +} + +QVariant GameListModel::data(const QModelIndex& index, int role, const GameList::Entry* ge) const +{ + switch (role) + { + case Qt::DisplayRole: + { + switch (index.column()) + { + case Column_Serial: + return QtUtils::StringViewToQString(ge->serial); + + case Column_Title: + return QtUtils::StringViewToQString(ge->title); + + case Column_FileTitle: + return QtUtils::StringViewToQString(Path::GetFileTitle(ge->path)); + + case Column_Developer: + return ge->dbentry ? QtUtils::StringViewToQString(ge->dbentry->developer) : QString(); + + case Column_Publisher: + return ge->dbentry ? QtUtils::StringViewToQString(ge->dbentry->publisher) : QString(); + + case Column_Genre: + return ge->dbentry ? QtUtils::StringViewToQString(ge->dbentry->genre) : QString(); + + case Column_Year: + { + if (ge->dbentry && ge->dbentry->release_date != 0) + { + return QStringLiteral("%1").arg( + QDateTime::fromSecsSinceEpoch(static_cast(ge->dbentry->release_date), QTimeZone::utc()) + .date() + .year()); + } + else + { + return QString(); + } + } + + case Column_Players: + { + if (!ge->dbentry || ge->dbentry->min_players == 0) + return QString(); + else if (ge->dbentry->min_players == ge->dbentry->max_players) + return QStringLiteral("%1").arg(ge->dbentry->min_players); + else + return QStringLiteral("%1-%2").arg(ge->dbentry->min_players).arg(ge->dbentry->max_players); + } + + case Column_FileSize: + return (ge->file_size >= 0) ? + QStringLiteral("%1 MB").arg(static_cast(ge->file_size) / 1048576.0, 0, 'f', 2) : + tr("Unknown"); + + case Column_UncompressedSize: + return QStringLiteral("%1 MB").arg(static_cast(ge->uncompressed_size) / 1048576.0, 0, 'f', 2); + + case Column_Achievements: + return {}; + + case Column_TimePlayed: + { + if (ge->total_played_time == 0) + return {}; + else + return formatTimespan(ge->total_played_time); + } + + case Column_LastPlayed: + return QtUtils::StringViewToQString(GameList::FormatTimestamp(ge->last_played_time)); + + case Column_Cover: + { + if (m_show_titles_for_covers) + return QString::fromStdString(ge->title); + else + return {}; + } + + default: + return {}; + } + } + + case Qt::InitialSortOrderRole: + { + const int column = index.column(); + if (column == Column_TimePlayed || column == Column_LastPlayed) + return Qt::DescendingOrder; + else + return Qt::AscendingOrder; + } + + case Qt::DecorationRole: + { + switch (index.column()) + { + case Column_Icon: + { + return getIconPixmapForEntry(ge); + } + + case Column_Region: + { + return getFlagPixmapForEntry(ge); + } + + case Column_Compatibility: + { + return m_compatibility_pixmaps[static_cast(ge->dbentry ? ge->dbentry->compatibility : + GameDatabase::CompatibilityRating::Unknown)]; + } + + case Column_Cover: + { + QPixmap* pm = m_cover_pixmap_cache.Lookup(ge->path); + if (pm) + return *pm; + + // We insert the placeholder into the cache, so that we don't repeatedly + // queue loading jobs for this game. + const_cast(this)->loadOrGenerateCover(ge); + return *m_cover_pixmap_cache.Insert(ge->path, m_loading_pixmap); + } + break; + + default: + return {}; + } + + default: + return {}; + } + } +} + +QVariant GameListModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole || section < 0 || section >= Column_Count) + return {}; + + return m_column_display_names[section]; +} + +const GameList::Entry* GameListModel::getTakenGameListEntry(u32 index) const +{ + return (m_taken_entries.has_value() && index < m_taken_entries->size()) ? &m_taken_entries.value()[index] : nullptr; +} + +bool GameListModel::hasTakenGameList() const +{ + return m_taken_entries.has_value(); +} + +void GameListModel::takeGameList() +{ + const auto lock = GameList::GetLock(); + m_taken_entries = GameList::TakeEntryList(); + + // If it's empty (e.g. first boot), don't use it. + if (m_taken_entries->empty()) + m_taken_entries.reset(); +} + +void GameListModel::refresh() +{ + beginResetModel(); + + m_taken_entries.reset(); + + // Invalidate memcard LRU cache, forcing a re-query of the memcard timestamps. + m_memcard_pixmap_cache.Clear(); + + endResetModel(); +} + +bool GameListModel::titlesLessThan(const GameList::Entry* left, const GameList::Entry* right) const +{ + return (StringUtil::Strcasecmp(left->title.c_str(), right->title.c_str()) < 0); +} + +bool GameListModel::lessThan(const QModelIndex& left_index, const QModelIndex& right_index, int column) const +{ + if (!left_index.isValid() || !right_index.isValid()) + return false; + + const int left_row = left_index.row(); + const int right_row = right_index.row(); + + if (m_taken_entries.has_value()) [[unlikely]] + { + const GameList::Entry* left = + (static_cast(left_row) < m_taken_entries->size()) ? &m_taken_entries.value()[left_row] : nullptr; + const GameList::Entry* right = + (static_cast(right_row) < m_taken_entries->size()) ? &m_taken_entries.value()[right_row] : nullptr; + if (!left || !right) + return false; + + return lessThan(left, right, column); + } + else + { + const auto lock = GameList::GetLock(); + const GameList::Entry* left = GameList::GetEntryByIndex(left_row); + const GameList::Entry* right = GameList::GetEntryByIndex(right_row); + if (!left || !right) + return false; + + return lessThan(left, right, column); + } +} + +bool GameListModel::lessThan(const GameList::Entry* left, const GameList::Entry* right, int column) const +{ + switch (column) + { + case Column_Icon: + { + const GameList::EntryType lst = left->GetSortType(); + const GameList::EntryType rst = right->GetSortType(); + if (lst == rst) + return titlesLessThan(left, right); + + return (static_cast(lst) < static_cast(rst)); + } + + case Column_Serial: + { + if (left->serial == right->serial) + return titlesLessThan(left, right); + return (StringUtil::Strcasecmp(left->serial.c_str(), right->serial.c_str()) < 0); + } + + case Column_Title: + { + return titlesLessThan(left, right); + } + + case Column_FileTitle: + { + const std::string_view file_title_left = Path::GetFileTitle(left->path); + const std::string_view file_title_right = Path::GetFileTitle(right->path); + if (file_title_left == file_title_right) + return titlesLessThan(left, right); + + const std::size_t smallest = std::min(file_title_left.size(), file_title_right.size()); + return (StringUtil::Strncasecmp(file_title_left.data(), file_title_right.data(), smallest) < 0); + } + + case Column_Region: + { + if (left->region == right->region) + return titlesLessThan(left, right); + return (static_cast(left->region) < static_cast(right->region)); + } + + case Column_Compatibility: + { + const GameDatabase::CompatibilityRating left_compatibility = + left->dbentry ? left->dbentry->compatibility : GameDatabase::CompatibilityRating::Unknown; + const GameDatabase::CompatibilityRating right_compatibility = + right->dbentry ? right->dbentry->compatibility : GameDatabase::CompatibilityRating::Unknown; + if (left_compatibility == right_compatibility) + return titlesLessThan(left, right); + + return (static_cast(left_compatibility) < static_cast(right_compatibility)); + } + + case Column_FileSize: + { + if (left->file_size == right->file_size) + return titlesLessThan(left, right); + + return (left->file_size < right->file_size); + } + + case Column_UncompressedSize: + { + if (left->uncompressed_size == right->uncompressed_size) + return titlesLessThan(left, right); + + return (left->uncompressed_size < right->uncompressed_size); + } + + case Column_Genre: + { + const int compres = + StringUtil::CompareNoCase(left->dbentry ? std::string_view(left->dbentry->genre) : std::string_view(), + right->dbentry ? std::string_view(right->dbentry->genre) : std::string_view()); + return (compres == 0) ? titlesLessThan(left, right) : (compres < 0); + } + + case Column_Developer: + { + const int compres = + StringUtil::CompareNoCase(left->dbentry ? std::string_view(left->dbentry->developer) : std::string_view(), + right->dbentry ? std::string_view(right->dbentry->developer) : std::string_view()); + return (compres == 0) ? titlesLessThan(left, right) : (compres < 0); + } + + case Column_Publisher: + { + const int compres = + StringUtil::CompareNoCase(left->dbentry ? std::string_view(left->dbentry->publisher) : std::string_view(), + right->dbentry ? std::string_view(right->dbentry->publisher) : std::string_view()); + return (compres == 0) ? titlesLessThan(left, right) : (compres < 0); + } + + case Column_Year: + { + const u64 ldate = left->dbentry ? left->dbentry->release_date : 0; + const u64 rdate = right->dbentry ? right->dbentry->release_date : 0; + if (ldate == rdate) + return titlesLessThan(left, right); + + return (ldate < rdate); + } + + case Column_TimePlayed: + { + if (left->total_played_time == right->total_played_time) + return titlesLessThan(left, right); + + return (left->total_played_time < right->total_played_time); + } + + case Column_LastPlayed: + { + if (left->last_played_time == right->last_played_time) + return titlesLessThan(left, right); + + return (left->last_played_time < right->last_played_time); + } + + case Column_Players: + { + const u8 left_players = left->dbentry ? ((left->dbentry->min_players << 4) + left->dbentry->max_players) : 0; + const u8 right_players = right->dbentry ? ((right->dbentry->min_players << 4) + right->dbentry->max_players) : 0; + if (left_players == right_players) + return titlesLessThan(left, right); + + return (left_players < right_players); + } + + case Column_Achievements: + { + // sort by unlock percentage + const float unlock_left = + (left->num_achievements > 0) ? + (static_cast(std::max(left->unlocked_achievements, left->unlocked_achievements_hc)) / + static_cast(left->num_achievements)) : + 0; + const float unlock_right = + (right->num_achievements > 0) ? + (static_cast(std::max(right->unlocked_achievements, right->unlocked_achievements_hc)) / + static_cast(right->num_achievements)) : + 0; + if (std::abs(unlock_left - unlock_right) < 0.0001f) + { + // order by achievement count + if (left->num_achievements == right->num_achievements) + return titlesLessThan(left, right); + + return (left->num_achievements < right->num_achievements); + } + + return (unlock_left < unlock_right); + } + + default: + return false; + } +} + +void GameListModel::loadThemeSpecificImages() +{ + for (u32 i = 0; i < static_cast(GameList::EntryType::Count); i++) + m_type_pixmaps[i] = QtUtils::GetIconForEntryType(static_cast(i)).pixmap(QSize(24, 24)); +} + +void GameListModel::loadCommonImages() +{ + loadThemeSpecificImages(); + + for (int i = 0; i < static_cast(GameDatabase::CompatibilityRating::Count); i++) + { + m_compatibility_pixmaps[i] = + QtUtils::GetIconForCompatibility(static_cast(i)).pixmap(96, 24); + } + + constexpr int ACHIEVEMENT_ICON_SIZE = 16; + m_no_achievements_pixmap = QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon-gray.svg", true))) + .pixmap(ACHIEVEMENT_ICON_SIZE); + m_has_achievements_pixmap = QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon.svg", true))) + .pixmap(ACHIEVEMENT_ICON_SIZE); + m_mastered_achievements_pixmap = + QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon-star.svg", true))) + .pixmap(ACHIEVEMENT_ICON_SIZE); +} + +void GameListModel::setColumnDisplayNames() +{ + m_column_display_names[Column_Icon] = tr("Icon"); + m_column_display_names[Column_Serial] = tr("Serial"); + m_column_display_names[Column_Title] = tr("Title"); + m_column_display_names[Column_FileTitle] = tr("File Title"); + m_column_display_names[Column_Developer] = tr("Developer"); + m_column_display_names[Column_Publisher] = tr("Publisher"); + m_column_display_names[Column_Genre] = tr("Genre"); + m_column_display_names[Column_Year] = tr("Year"); + m_column_display_names[Column_Players] = tr("Players"); + m_column_display_names[Column_Achievements] = tr("Achievements"); + m_column_display_names[Column_TimePlayed] = tr("Time Played"); + m_column_display_names[Column_LastPlayed] = tr("Last Played"); + m_column_display_names[Column_FileSize] = tr("Size"); + m_column_display_names[Column_UncompressedSize] = tr("Raw Size"); + m_column_display_names[Column_Region] = tr("Region"); + m_column_display_names[Column_Compatibility] = tr("Compatibility"); +} + class GameListSortModel final : public QSortFilterProxyModel { public: diff --git a/src/duckstation-qt/gamelistwidget.h b/src/duckstation-qt/gamelistwidget.h index 70992a498..045ddfb3d 100644 --- a/src/duckstation-qt/gamelistwidget.h +++ b/src/duckstation-qt/gamelistwidget.h @@ -2,20 +2,179 @@ // SPDX-License-Identifier: CC-BY-NC-ND-4.0 #pragma once + #include "ui_emptygamelistwidget.h" #include "ui_gamelistwidget.h" +#include "core/game_database.h" #include "core/game_list.h" +#include "core/types.h" +#include "common/heterogeneous_containers.h" +#include "common/lru_cache.h" + +#include +#include +#include #include #include +#include +#include +#include + Q_DECLARE_METATYPE(const GameList::Entry*); class GameListModel; class GameListSortModel; class GameListRefreshThread; +class GameListModel final : public QAbstractTableModel +{ + Q_OBJECT + +public: + enum Column : int + { + Column_Icon, + Column_Serial, + Column_Title, + Column_FileTitle, + Column_Developer, + Column_Publisher, + Column_Genre, + Column_Year, + Column_Players, + Column_TimePlayed, + Column_LastPlayed, + Column_FileSize, + Column_UncompressedSize, + Column_Region, + Column_Achievements, + Column_Compatibility, + Column_Cover, + + Column_Count + }; + + static std::optional getColumnIdForName(std::string_view name); + static const char* getColumnName(Column col); + + GameListModel(float cover_scale, bool show_cover_titles, bool show_game_icons, QObject* parent = nullptr); + ~GameListModel(); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + + ALWAYS_INLINE const QString& getColumnDisplayName(int column) const { return m_column_display_names[column]; } + ALWAYS_INLINE const QPixmap& getNoAchievementsPixmap() const { return m_no_achievements_pixmap; } + ALWAYS_INLINE const QPixmap& getHasAchievementsPixmap() const { return m_has_achievements_pixmap; } + ALWAYS_INLINE const QPixmap& getMasteredAchievementsPixmap() const { return m_mastered_achievements_pixmap; } + + const GameList::Entry* getTakenGameListEntry(u32 index) const; + bool hasTakenGameList() const; + void takeGameList(); + + void refresh(); + void reloadThemeSpecificImages(); + + bool titlesLessThan(const GameList::Entry* left, const GameList::Entry* right) const; + bool lessThan(const GameList::Entry* left, const GameList::Entry* right, int column) const; + + bool lessThan(const QModelIndex& left_index, const QModelIndex& right_index, int column) const; + + bool getShowCoverTitles() const { return m_show_titles_for_covers; } + void setShowCoverTitles(bool enabled) { m_show_titles_for_covers = enabled; } + + bool getShowGameIcons() const { return m_show_game_icons; } + void setShowGameIcons(bool enabled); + QIcon getIconForGame(const QString& path); + + float getCoverScale() const { return m_cover_scale; } + void setCoverScale(float scale); + int getCoverArtWidth() const; + int getCoverArtHeight() const; + int getCoverArtSpacing() const; + void refreshCovers(); + void updateCacheSize(int width, int height); + +Q_SIGNALS: + void coverScaleChanged(); + +private Q_SLOTS: + void coverLoaded(const std::string& path, const QImage& image, float scale); + void rowsChanged(const QList& rows); + +private: + QVariant data(const QModelIndex& index, int role, const GameList::Entry* ge) const; + + void loadCommonImages(); + void loadThemeSpecificImages(); + void setColumnDisplayNames(); + void loadOrGenerateCover(const GameList::Entry* ge); + void invalidateCoverForPath(const std::string& path); + + const QPixmap& getIconPixmapForEntry(const GameList::Entry* ge) const; + const QPixmap& getFlagPixmapForEntry(const GameList::Entry* ge) const; + static void fixIconPixmapSize(QPixmap& pm); + + static QString formatTimespan(time_t timespan); + + std::optional m_taken_entries; + + float m_cover_scale = 0.0f; + bool m_show_titles_for_covers = false; + bool m_show_game_icons = false; + + std::array m_column_display_names; + std::array(GameList::EntryType::Count)> m_type_pixmaps; + std::array(GameDatabase::CompatibilityRating::Count)> m_compatibility_pixmaps; + + QImage m_placeholder_image; + QPixmap m_loading_pixmap; + + QPixmap m_no_achievements_pixmap; + QPixmap m_has_achievements_pixmap; + QPixmap m_mastered_achievements_pixmap; + + mutable PreferUnorderedStringMap m_flag_pixmap_cache; + + mutable LRUCache m_cover_pixmap_cache; + + 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 diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp index bd0025cab..a0d9d551e 100644 --- a/src/duckstation-qt/mainwindow.cpp +++ b/src/duckstation-qt/mainwindow.cpp @@ -8,7 +8,6 @@ #include "coverdownloaddialog.h" #include "debuggerwindow.h" #include "displaywidget.h" -#include "gamelistmodel.h" #include "gamelistsettingswidget.h" #include "gamelistwidget.h" #include "interfacesettingswidget.h"