// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin // SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) #include "gamelistmodel.h" #include "qthost.h" #include "qtutils.h" #include "core/system.h" #include "common/file_system.h" #include "common/log.h" #include "common/path.h" #include "common/string_util.h" #include #include #include #include #include #include #include #include Log_SetChannel(GameList); 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", "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 int DPRScale(int size, float dpr) { return static_cast(static_cast(size) * dpr); } static int DPRUnscale(int size, float dpr) { return static_cast(static_cast(size) / dpr); } static void resizeAndPadPixmap(QPixmap* pm, int expected_width, int expected_height, float dpr) { const int dpr_expected_width = DPRScale(expected_width, dpr); const int dpr_expected_height = DPRScale(expected_height, dpr); if (pm->width() == dpr_expected_width && pm->height() == dpr_expected_height) return; *pm = pm->scaled(dpr_expected_width, dpr_expected_height, Qt::KeepAspectRatio, Qt::SmoothTransformation); if (pm->width() == dpr_expected_width && pm->height() == dpr_expected_height) return; // QPainter works in unscaled coordinates. int xoffs = 0; int yoffs = 0; if (pm->width() < dpr_expected_width) xoffs = DPRUnscale((dpr_expected_width - pm->width()) / 2, dpr); if (pm->height() < dpr_expected_height) yoffs = DPRUnscale((dpr_expected_height - pm->height()) / 2, dpr); QPixmap padded_image(dpr_expected_width, dpr_expected_height); padded_image.setDevicePixelRatio(dpr); padded_image.fill(Qt::transparent); QPainter painter; if (painter.begin(&padded_image)) { painter.setCompositionMode(QPainter::CompositionMode_Source); painter.drawPixmap(xoffs, yoffs, *pm); painter.setCompositionMode(QPainter::CompositionMode_Destination); painter.fillRect(padded_image.rect(), QColor(0, 0, 0, 0)); painter.end(); } *pm = padded_image; } static QPixmap createPlaceholderImage(const QPixmap& placeholder_pixmap, int width, int height, float scale, const std::string& title) { const float dpr = qApp->devicePixelRatio(); QPixmap pm(placeholder_pixmap.copy()); pm.setDevicePixelRatio(dpr); if (pm.isNull()) return QPixmap(); resizeAndPadPixmap(&pm, width, height, dpr); QPainter painter; if (painter.begin(&pm)) { 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(); } return pm; } 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(128) { loadCommonImages(); setCoverScale(cover_scale); setColumnDisplayNames(); if (m_show_game_icons) GameList::ReloadMemcardTimestampCache(); } 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; m_loading_pixmap = QPixmap(getCoverArtWidth(), getCoverArtHeight()); m_loading_pixmap.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) { QFuture future = QtConcurrent::run([this, path = ge->path, title = ge->title, serial = ge->serial]() -> QPixmap { QPixmap image; const std::string cover_path(GameList::GetCoverImagePath(path, serial, title)); if (!cover_path.empty()) { const float dpr = qApp->devicePixelRatio(); image = QPixmap(QString::fromStdString(cover_path)); if (!image.isNull()) { image.setDevicePixelRatio(dpr); resizeAndPadPixmap(&image, getCoverArtWidth(), getCoverArtHeight(), dpr); } } if (image.isNull()) image = createPlaceholderImage(m_placeholder_pixmap, getCoverArtWidth(), getCoverArtHeight(), m_cover_scale, title); return image; }); // Context must be 'this' so we run on the UI thread. future.then(this, [this, path = ge->path](QPixmap pm) { m_cover_pixmap_cache.Insert(std::move(path), std::move(pm)); invalidateCoverForPath(path); }); } void GameListModel::invalidateCoverForPath(const std::string& path) { // 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(); std::optional row; 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)]; } 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 int width = pm.width(); const int height = pm.height(); const int max_dim = std::max(width, height); if (max_dim == 16) return; const float scale = static_cast(max_dim) / 16.0f; 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()) return 0; 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()) return {}; const int row = index.row(); if (row < 0 || row >= static_cast(GameList::GetEntryCount())) return {}; const auto lock = GameList::GetLock(); const GameList::Entry* ge = GameList::GetEntryByIndex(row); if (!ge) return {}; switch (role) { case Qt::DisplayRole: { switch (index.column()) { case Column_Serial: return QString::fromStdString(ge->serial); case Column_Title: return QString::fromStdString(ge->title); case Column_FileTitle: return QtUtils::StringViewToQString(Path::GetFileTitle(ge->path)); case Column_Developer: return QString::fromStdString(ge->developer); case Column_Publisher: return QString::fromStdString(ge->publisher); case Column_Genre: return QString::fromStdString(ge->genre); case Column_Year: { if (ge->release_date != 0) { return QStringLiteral("%1").arg( QDateTime::fromSecsSinceEpoch(static_cast(ge->release_date), Qt::UTC).date().year()); } else { return QString(); } } case Column_Players: { if (ge->min_players == ge->max_players) return QStringLiteral("%1").arg(ge->min_players); else return QStringLiteral("%1-%2").arg(ge->min_players).arg(ge->max_players); } case Column_FileSize: return (ge->file_size >= 0) ? QString("%1 MB").arg(static_cast(ge->file_size) / 1048576.0, 0, 'f', 2) : tr("Unknown"); case Column_UncompressedSize: return QString("%1 MB").arg(static_cast(ge->uncompressed_size) / 1048576.0, 0, 'f', 2); 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: { switch (index.column()) { case Column_Icon: return static_cast(ge->GetSortType()); case Column_Serial: return QString::fromStdString(ge->serial); case Column_Title: case Column_Cover: return QString::fromStdString(ge->title); case Column_FileTitle: return QtUtils::StringViewToQString(Path::GetFileTitle(ge->path)); case Column_Developer: return QString::fromStdString(ge->developer); case Column_Publisher: return QString::fromStdString(ge->publisher); case Column_Genre: return QString::fromStdString(ge->genre); case Column_Year: return QDateTime::fromSecsSinceEpoch(static_cast(ge->release_date), Qt::UTC).date().year(); case Column_Players: return static_cast(ge->max_players); case Column_Region: return static_cast(ge->region); case Column_Compatibility: return static_cast(ge->compatibility); case Column_TimePlayed: return static_cast(ge->total_played_time); case Column_LastPlayed: return static_cast(ge->last_played_time); case Column_FileSize: return static_cast(ge->file_size); case Column_UncompressedSize: return static_cast(ge->uncompressed_size); default: return {}; } } case Qt::DecorationRole: { switch (index.column()) { case Column_Icon: { return getIconPixmapForEntry(ge); } case Column_Region: { return m_region_pixmaps[static_cast(ge->region)]; } case Column_Compatibility: { return m_compatibility_pixmaps[static_cast(ge->compatibility)]; } 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]; } void GameListModel::refresh() { beginResetModel(); // Invalidate memcard LRU cache, forcing a re-query of the memcard timestamps. m_memcard_pixmap_cache.Clear(); endResetModel(); } bool GameListModel::titlesLessThan(int left_row, int right_row) const { if (left_row < 0 || left_row >= static_cast(GameList::GetEntryCount()) || right_row < 0 || right_row >= static_cast(GameList::GetEntryCount())) { return false; } const GameList::Entry* left = GameList::GetEntryByIndex(left_row); const GameList::Entry* right = GameList::GetEntryByIndex(right_row); 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 (left_row < 0 || left_row >= static_cast(GameList::GetEntryCount()) || right_row < 0 || right_row >= static_cast(GameList::GetEntryCount())) { return false; } 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; switch (column) { case Column_Icon: { const GameList::EntryType lst = left->GetSortType(); const GameList::EntryType rst = right->GetSortType(); if (lst == rst) return titlesLessThan(left_row, right_row); return (static_cast(lst) < static_cast(rst)); } case Column_Serial: { if (left->serial == right->serial) return titlesLessThan(left_row, right_row); return (StringUtil::Strcasecmp(left->serial.c_str(), right->serial.c_str()) < 0); } case Column_Title: { return titlesLessThan(left_row, right_row); } 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_row, right_row); 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_row, right_row); return (static_cast(left->region) < static_cast(right->region)); } case Column_Compatibility: { if (left->compatibility == right->compatibility) return titlesLessThan(left_row, right_row); return (static_cast(left->compatibility) < static_cast(right->compatibility)); } case Column_FileSize: { if (left->file_size == right->file_size) return titlesLessThan(left_row, right_row); return (left->file_size < right->file_size); } case Column_UncompressedSize: { if (left->uncompressed_size == right->uncompressed_size) return titlesLessThan(left_row, right_row); return (left->uncompressed_size < right->uncompressed_size); } case Column_Genre: { if (left->genre == right->genre) return titlesLessThan(left_row, right_row); return (StringUtil::Strcasecmp(left->genre.c_str(), right->genre.c_str()) < 0); } case Column_Developer: { if (left->developer == right->developer) return titlesLessThan(left_row, right_row); return (StringUtil::Strcasecmp(left->developer.c_str(), right->developer.c_str()) < 0); } case Column_Publisher: { if (left->publisher == right->publisher) return titlesLessThan(left_row, right_row); return (StringUtil::Strcasecmp(left->publisher.c_str(), right->publisher.c_str()) < 0); } case Column_Year: { if (left->release_date == right->release_date) return titlesLessThan(left_row, right_row); return (left->release_date < right->release_date); } case Column_TimePlayed: { if (left->total_played_time == right->total_played_time) return titlesLessThan(left_row, right_row); return (left->total_played_time < right->total_played_time); } case Column_LastPlayed: { if (left->last_played_time == right->last_played_time) return titlesLessThan(left_row, right_row); return (left->last_played_time < right->last_played_time); } case Column_Players: { u8 left_players = (left->min_players << 4) + left->max_players; u8 right_players = (right->min_players << 4) + right->max_players; if (left_players == right_players) return titlesLessThan(left_row, right_row); return (left_players < right_players); } 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)); for (u32 i = 0; i < static_cast(DiscRegion::Count); i++) m_region_pixmaps[i] = QtUtils::GetIconForRegion(static_cast(i)).pixmap(42, 30); } 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); } m_placeholder_pixmap.load(QStringLiteral("%1/images/cover-placeholder.png").arg(QtHost::GetResourcesBasePath())); } 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_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"); }