diff options
| author | crueter <crueter@eden-emu.dev> | 2026-06-02 04:08:24 +0200 |
|---|---|---|
| committer | crueter <crueter@eden-emu.dev> | 2026-06-02 04:08:24 +0200 |
| commit | cc8451f764979db23208dd5a55884cd62fd8b673 (patch) | |
| tree | 5fe5dc8e28322f897492639e7c895d226f9bcdc5 /src/qt_common/game_list | |
| parent | 27189f39d2bf78c924475ac6ea2a79c3d6ec6d8a (diff) | |
[desktop] Rework game list to use MVP architecture (#4042)
Closes #3480
moves the game list model/worker/private stuff to qt_common for later
use in QML
- `qt_common/game_list/model.{cpp,h}` is the model
- `yuzu/game/game_{grid,tree}.*` are the views
- `yuzu/game/game_list.cpp` is the presenter
This was done very lazily in a manner that "works" while largely
maintaining existing structure as much as possible. Most of it is
copy-paste, with some bonus reworks/cleanups thrown in.
Signed-off-by: crueter <crueter@eden-emu.dev>
Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/4042
Reviewed-by: MaranBr <maranbr@eden-emu.dev>
Reviewed-by: Lizzie <lizzie@eden-emu.dev>
Diffstat (limited to 'src/qt_common/game_list')
| -rw-r--r-- | src/qt_common/game_list/game_list_p.h | 399 | ||||
| -rw-r--r-- | src/qt_common/game_list/model.cpp | 303 | ||||
| -rw-r--r-- | src/qt_common/game_list/model.h | 98 | ||||
| -rw-r--r-- | src/qt_common/game_list/worker.cpp | 497 | ||||
| -rw-r--r-- | src/qt_common/game_list/worker.h | 100 |
5 files changed, 1397 insertions, 0 deletions
diff --git a/src/qt_common/game_list/game_list_p.h b/src/qt_common/game_list/game_list_p.h new file mode 100644 index 0000000000..3994887755 --- /dev/null +++ b/src/qt_common/game_list/game_list_p.h @@ -0,0 +1,399 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: 2015 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <array> +#include <map> +#include <string> +#include <utility> + +#include <QCoreApplication> +#include <QFileInfo> +#include <QObject> +#include <QRegularExpression> +#include <QStandardItem> +#include <QString> + +#include "common/common_types.h" +#include "common/logging.h" +#include "common/string_util.h" +#include "frontend_common/play_time_manager.h" +#include "qt_common/config/uisettings.h" +#include "qt_common/qt_common.h" + +enum class GameListItemType { + Game = QStandardItem::UserType + 1, + CustomDir = QStandardItem::UserType + 2, + SdmcDir = QStandardItem::UserType + 3, + UserNandDir = QStandardItem::UserType + 4, + SysNandDir = QStandardItem::UserType + 5, + AddDir = QStandardItem::UserType + 6, + Favorites = QStandardItem::UserType + 7, +}; + +Q_DECLARE_METATYPE(GameListItemType); + +/** + * Gets the default icon (for games without valid title metadata) + * @param size The desired width and height of the default icon. + * @return QPixmap default icon + */ +static QPixmap GetDefaultIcon(u32 size) { + QPixmap icon(size, size); + icon.fill(Qt::transparent); + return icon; +} + +class GameListItem : public QStandardItem { + +public: + // used to access type from item index + static constexpr int TypeRole = Qt::UserRole + 1; + static constexpr int SortRole = Qt::UserRole + 2; + GameListItem() = default; + explicit GameListItem(const QString& string) : QStandardItem(string) { + setData(string, SortRole); + } +}; + +/** + * A specialization of GameListItem for path values. + * This class ensures that for every full path value it holds, a correct string representation + * of just the filename (with no extension) will be displayed to the user. + * If this class receives valid title metadata, it will also display game icons and titles. + */ +class GameListItemPath : public GameListItem { +public: + static constexpr int TitleRole = SortRole + 1; + static constexpr int FullPathRole = SortRole + 2; + static constexpr int ProgramIdRole = SortRole + 3; + static constexpr int FileTypeRole = SortRole + 4; + + GameListItemPath() = default; + GameListItemPath(const QString& game_path, const std::vector<u8>& picture_data, + const QString& game_name, const QString& game_type, u64 program_id, + u64 play_time, const QString& patch_versions) { + setData(type(), TypeRole); + setData(game_path, FullPathRole); + setData(game_name, TitleRole); + setData(qulonglong(program_id), ProgramIdRole); + setData(game_type, FileTypeRole); + + const auto readable_play_time = + play_time > 0 ? QObject::tr("Play Time: %1") + .arg(QString::fromStdString( + PlayTime::PlayTimeManager::GetReadablePlayTime(play_time))) + : QObject::tr("Never Played"); + + const auto enabled_update = [patch_versions]() -> QString { + const QStringList lines = patch_versions.split(QLatin1Char('\n')); + const QRegularExpression regex{QStringLiteral(R"(^Update \(([0-9\.]+)\))")}; + for (const QString& line : std::as_const(lines)) { + const auto match = regex.match(line); + if (match.hasMatch() && match.hasCaptured(1)) + return QObject::tr("Version: %1").arg(match.captured(1)); + } + return QObject::tr("Version: 1.0.0"); + }(); + + const auto tooltip = QStringLiteral("%1\n%2").arg(readable_play_time, enabled_update); + + setData(tooltip, Qt::ToolTipRole); + + const u32 size = UISettings::values.game_icon_size.GetValue(); + + QPixmap picture; + if (!picture.loadFromData(picture_data.data(), static_cast<u32>(picture_data.size()))) { + picture = GetDefaultIcon(size); + } + picture = picture.scaled(size, size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + + setData(picture, Qt::DecorationRole); + } + + int type() const override { + return static_cast<int>(GameListItemType::Game); + } + + QVariant data(int role) const override { + if (role == Qt::DisplayRole || role == SortRole) { + std::string filename; + Common::SplitPath(data(FullPathRole).toString().toStdString(), nullptr, &filename, + nullptr); + + const std::array<QString, 4> row_data{{ + QString::fromStdString(filename), + data(FileTypeRole).toString(), + QString::fromStdString(fmt::format("0x{:016X}", data(ProgramIdRole).toULongLong())), + data(TitleRole).toString(), + }}; + + const auto& row1 = row_data.at(UISettings::values.row_1_text_id.GetValue()); + // don't show row 2 on grid view + switch (UISettings::values.game_list_mode.GetValue()) { + + case Settings::GameListMode::TreeView: { + const int row2_id = UISettings::values.row_2_text_id.GetValue(); + + if (role == SortRole) { + return row1.toLower(); + } + + // None + if (row2_id == 4) { + return row1; + } + + const auto& row2 = row_data.at(row2_id); + + if (row1 == row2) { + return row1; + } + + return QStringLiteral("%1\n %2").arg(row1, row2); + } + case Settings::GameListMode::GridView: + return row1; + default: + break; + } + } + + return GameListItem::data(role); + } +}; + +class GameListItemCompat : public GameListItem { + Q_DECLARE_TR_FUNCTIONS(GameListItemCompat) +public: + static constexpr int CompatNumberRole = SortRole; + GameListItemCompat() = default; + explicit GameListItemCompat(const QString& compatibility) { + setData(type(), TypeRole); + + struct CompatStatus { + QString color; + const char* text; + const char* tooltip; + }; + // clang-format off + const auto ingame_status = + CompatStatus{QStringLiteral("#f2d624"), QT_TR_NOOP("Ingame"), QT_TR_NOOP("Game starts, but crashes or major glitches prevent it from being completed.")}; + static const std::map<QString, CompatStatus> status_data = { + {QStringLiteral("0"), {QStringLiteral("#5c93ed"), QT_TR_NOOP("Perfect"), QT_TR_NOOP("Game can be played without issues.")}}, + {QStringLiteral("1"), {QStringLiteral("#47d35c"), QT_TR_NOOP("Playable"), QT_TR_NOOP("Game functions with minor graphical or audio glitches and is playable from start to finish.")}}, + {QStringLiteral("2"), ingame_status}, + {QStringLiteral("3"), ingame_status}, // Fallback for the removed "Okay" category + {QStringLiteral("4"), {QStringLiteral("#FF0000"), QT_TR_NOOP("Intro/Menu"), QT_TR_NOOP("Game loads, but is unable to progress past the Start Screen.")}}, + {QStringLiteral("5"), {QStringLiteral("#828282"), QT_TR_NOOP("Won't Boot"), QT_TR_NOOP("The game crashes when attempting to startup.")}}, + {QStringLiteral("99"), {QStringLiteral("#000000"), QT_TR_NOOP("Not Tested"), QT_TR_NOOP("The game has not yet been tested.")}}, + }; + // clang-format on + + auto iterator = status_data.find(compatibility); + if (iterator == status_data.end()) { + LOG_WARNING(Frontend, "Invalid compatibility number {}", compatibility.toStdString()); + return; + } + const CompatStatus& status = iterator->second; + setData(compatibility, CompatNumberRole); + setText(tr(status.text)); + setToolTip(tr(status.tooltip)); + setData(QtCommon::CreateCirclePixmapFromColor(status.color), Qt::DecorationRole); + } + + int type() const override { + return static_cast<int>(GameListItemType::Game); + } + + bool operator<(const QStandardItem& other) const override { + return data(CompatNumberRole).value<QString>() < + other.data(CompatNumberRole).value<QString>(); + } +}; + +/** + * A specialization of GameListItem for size values. + * This class ensures that for every numerical size value it holds (in bytes), a correct + * human-readable string representation will be displayed to the user. + */ +class GameListItemSize : public GameListItem { +public: + static constexpr int SizeRole = SortRole; + + GameListItemSize() = default; + explicit GameListItemSize(const qulonglong size_bytes) { + setData(type(), TypeRole); + setData(size_bytes, SizeRole); + } + + void setData(const QVariant& value, int role) override { + // By specializing setData for SizeRole, we can ensure that the numerical and string + // representations of the data are always accurate and in the correct format. + if (role == SizeRole) { + qulonglong size_bytes = value.toULongLong(); + GameListItem::setData(QtCommon::ReadableByteSize(size_bytes), Qt::DisplayRole); + GameListItem::setData(value, SizeRole); + } else { + GameListItem::setData(value, role); + } + } + + int type() const override { + return static_cast<int>(GameListItemType::Game); + } + + /** + * This operator is, in practice, only used by the TreeView sorting systems. + * Override it so that it will correctly sort by numerical value instead of by string + * representation. + */ + bool operator<(const QStandardItem& other) const override { + return data(SizeRole).toULongLong() < other.data(SizeRole).toULongLong(); + } +}; + +/** + * GameListItem for Play Time values. + * This object stores the play time of a game in seconds, and its readable + * representation in minutes/hours + */ +class GameListItemPlayTime : public GameListItem { +public: + static constexpr int PlayTimeRole = SortRole; + + GameListItemPlayTime() = default; + explicit GameListItemPlayTime(const qulonglong time_seconds) { + setData(time_seconds, PlayTimeRole); + } + + void setData(const QVariant& value, int role) override { + qulonglong time_seconds = value.toULongLong(); + GameListItem::setData( + QString::fromStdString(PlayTime::PlayTimeManager::GetReadablePlayTime(time_seconds)), + Qt::DisplayRole); + GameListItem::setData(value, PlayTimeRole); + } + + bool operator<(const QStandardItem& other) const override { + return data(PlayTimeRole).toULongLong() < other.data(PlayTimeRole).toULongLong(); + } +}; + +class GameListDir : public GameListItem { +public: + static constexpr int GameDirRole = Qt::UserRole + 2; + + explicit GameListDir(UISettings::GameDir& directory, + GameListItemType dir_type_ = GameListItemType::CustomDir) + : dir_type{dir_type_} { + setData(type(), TypeRole); + + UISettings::GameDir* game_dir = &directory; + setData(QVariant(UISettings::values.game_dirs.indexOf(directory)), GameDirRole); + + const int icon_size = UISettings::values.folder_icon_size.GetValue(); + switch (dir_type) { + case GameListItemType::SdmcDir: + setData( + QIcon::fromTheme(QStringLiteral("sd_card")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + setData(QObject::tr("Installed SD Titles"), Qt::DisplayRole); + break; + case GameListItemType::UserNandDir: + setData( + QIcon::fromTheme(QStringLiteral("chip")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + setData(QObject::tr("Installed NAND Titles"), Qt::DisplayRole); + break; + case GameListItemType::SysNandDir: + setData( + QIcon::fromTheme(QStringLiteral("chip")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + setData(QObject::tr("System Titles"), Qt::DisplayRole); + break; + case GameListItemType::CustomDir: { + const QString path = QString::fromStdString(game_dir->path); + const QString icon_name = + QFileInfo::exists(path) ? QStringLiteral("folder") : QStringLiteral("bad_folder"); + setData(QIcon::fromTheme(icon_name).pixmap(icon_size).scaled( + icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + setData(path, Qt::DisplayRole); + break; + } + default: + break; + } + } + + int type() const override { + return static_cast<int>(dir_type); + } + + /** + * Override to prevent automatic sorting between folders and the addDir button. + */ + bool operator<(const QStandardItem& other) const override { + return false; + } + +private: + GameListItemType dir_type; +}; + +class GameListAddDir : public GameListItem { +public: + explicit GameListAddDir() { + setData(type(), TypeRole); + + const int icon_size = UISettings::values.folder_icon_size.GetValue(); + + setData(QIcon::fromTheme(QStringLiteral("list-add")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + setData(QObject::tr("Add New Game Directory"), Qt::DisplayRole); + } + + int type() const override { + return static_cast<int>(GameListItemType::AddDir); + } + + bool operator<(const QStandardItem& other) const override { + return false; + } +}; + +class GameListFavorites : public GameListItem { +public: + explicit GameListFavorites() { + setData(type(), TypeRole); + + const int icon_size = UISettings::values.folder_icon_size.GetValue(); + + setData(QIcon::fromTheme(QStringLiteral("star")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + setData(QObject::tr("Favorites"), Qt::DisplayRole); + } + + int type() const override { + return static_cast<int>(GameListItemType::Favorites); + } + + bool operator<(const QStandardItem& other) const override { + return false; + } +}; diff --git a/src/qt_common/game_list/model.cpp b/src/qt_common/game_list/model.cpp new file mode 100644 index 0000000000..2f8deaa0c5 --- /dev/null +++ b/src/qt_common/game_list/model.cpp @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include <QDir> +#include <QIcon> +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonObject> +#include <QThreadPool> + +#include "common/logging.h" +#include "common/settings.h" +#include "core/core.h" +#include "core/file_sys/patch_manager.h" +#include "core/file_sys/registered_cache.h" +#include "core/hle/service/filesystem/filesystem.h" +#include "qt_common/config/uisettings.h" +#include "qt_common/qt_common.h" +#include "qt_common/util/game.h" + +#include "qt_common/game_list/game_list_p.h" +#include "qt_common/game_list/worker.h" +#include "qt_common/game_list/model.h" + +GameListModel::GameListModel(std::shared_ptr<FileSys::VfsFilesystem> vfs_, + FileSys::ManualContentProvider* provider_, + const PlayTime::PlayTimeManager& play_time_manager_, + Core::System& system_, QObject* parent) + : QStandardItemModel{parent}, vfs{std::move(vfs_)}, provider{provider_}, + play_time_manager{play_time_manager_}, system{system_} { + watcher = new QFileSystemWatcher(this); + external_watcher = new QFileSystemWatcher(this); + + connect(watcher, &QFileSystemWatcher::directoryChanged, this, + &GameListModel::RefreshGameDirectory); + connect(external_watcher, &QFileSystemWatcher::directoryChanged, this, + &GameListModel::RefreshExternalContent); + + insertColumns(0, COLUMN_COUNT); + RetranslateUI(); + + setSortRole(GameListItemPath::SortRole); +} + +GameListModel::~GameListModel() = default; + +void GameListModel::PopulateAsync(QVector<UISettings::GameDir>& game_dirs) { + current_worker.reset(); + removeRows(0, rowCount()); + + current_worker = std::make_unique<GameListWorker>(vfs, provider, game_dirs, compatibility_list, + play_time_manager, system); + + connect(current_worker.get(), &GameListWorker::DataAvailable, this, &GameListModel::WorkerEvent, + Qt::QueuedConnection); + + QThreadPool::globalInstance()->start(current_worker.get()); +} + +void GameListModel::WorkerEvent() { + current_worker->ProcessEvents(this); +} + +void GameListModel::AddDirEntry(GameListDir* entry_items) { + if (m_flat) { + return; + } + invisibleRootItem()->appendRow(entry_items); +} + +void GameListModel::AddEntry(const QList<QStandardItem*>& entry_items, GameListDir* parent) { + if (m_flat) { + invisibleRootItem()->appendRow(entry_items); + } else { + parent->appendRow(entry_items); + } +} + +void GameListModel::DonePopulating(const QStringList& watch_list) { + emit ShowList(!IsEmpty()); + + if (!m_flat) { + invisibleRootItem()->appendRow(new GameListAddDir()); + invisibleRootItem()->insertRow(0, new GameListFavorites()); + + for (const auto id : std::as_const(UISettings::values.favorited_ids)) { + AddFavorite(id); + } + } + + emit PopulatingCompleted(watch_list); +} + +bool GameListModel::IsEmpty() const { + for (int i = 0; i < rowCount(); i++) { + const QStandardItem* child = invisibleRootItem()->child(i); + const auto type = static_cast<GameListItemType>(child->type()); + + if (!child->hasChildren() && + (type == GameListItemType::SdmcDir || type == GameListItemType::UserNandDir || + type == GameListItemType::SysNandDir)) { + invisibleRootItem()->removeRow(child->row()); + i--; + } + } + + return !invisibleRootItem()->hasChildren(); +} + +void GameListModel::ToggleFavorite(u64 program_id) { + if (!UISettings::values.favorited_ids.contains(program_id)) { + UISettings::values.favorited_ids.append(program_id); + AddFavorite(program_id); + } else { + UISettings::values.favorited_ids.removeOne(program_id); + RemoveFavorite(program_id); + } + emit SaveConfig(); +} + +void GameListModel::AddFavorite(u64 program_id) { + auto* favorites_row = item(0); + + for (int i = 1; i < rowCount() - 1; i++) { + const auto* folder = item(i); + for (int j = 0; j < folder->rowCount(); j++) { + if (folder->child(j)->data(GameListItemPath::ProgramIdRole).toULongLong() == + program_id) { + QList<QStandardItem*> list; + for (int k = 0; k < COLUMN_COUNT; k++) { + list.append(folder->child(j, k)->clone()); + } + list[0]->setData(folder->child(j)->data(GameListItem::SortRole), + GameListItem::SortRole); + list[0]->setText(folder->child(j)->data(Qt::DisplayRole).toString()); + + favorites_row->appendRow(list); + return; + } + } + } +} + +void GameListModel::RemoveFavorite(u64 program_id) { + auto* favorites_row = item(0); + + for (int i = 0; i < favorites_row->rowCount(); i++) { + const auto* game = favorites_row->child(i); + if (game->data(GameListItemPath::ProgramIdRole).toULongLong() == program_id) { + favorites_row->removeRow(i); + return; + } + } +} + +void GameListModel::LoadCompatibilityList() { + QFile compat_list{QStringLiteral(":compatibility_list/compatibility_list.json")}; + + if (!compat_list.open(QFile::ReadOnly | QFile::Text)) { + LOG_ERROR(Frontend, "Unable to open game compatibility list"); + return; + } + + if (compat_list.size() == 0) { + LOG_WARNING(Frontend, "Game compatibility list is empty"); + return; + } + + const QByteArray content = compat_list.readAll(); + if (content.isEmpty()) { + LOG_ERROR(Frontend, "Unable to completely read game compatibility list"); + return; + } + + const QJsonDocument json = QJsonDocument::fromJson(content); + const QJsonArray arr = json.array(); + + for (const QJsonValue& value : arr) { + const QJsonObject game = value.toObject(); + const QString compatibility_key = QStringLiteral("compatibility"); + + if (!game.contains(compatibility_key) || !game[compatibility_key].isDouble()) { + continue; + } + + const int compatibility = game[compatibility_key].toInt(); + const QString directory = game[QStringLiteral("directory")].toString(); + const QJsonArray ids = game[QStringLiteral("releases")].toArray(); + + for (const QJsonValue& id_ref : ids) { + const QJsonObject id_object = id_ref.toObject(); + const QString id = id_object[QStringLiteral("id")].toString(); + + compatibility_list.emplace(id.toUpper().toStdString(), + std::make_pair(QString::number(compatibility), directory)); + } + } +} + +void GameListModel::RefreshGameDirectory() { + ResetExternalWatcher(); + + if (!UISettings::values.game_dirs.empty() && current_worker != nullptr) { + LOG_INFO(Frontend, "Change detected in the games directory. Reloading game list."); + QtCommon::system->GetFileSystemController().CreateFactories(*QtCommon::vfs); + PopulateAsync(UISettings::values.game_dirs); + } +} + +void GameListModel::RefreshExternalContent() { + if (!UISettings::values.game_dirs.empty() && current_worker != nullptr) { + LOG_INFO(Frontend, "External content directory changed. Clearing metadata cache."); + QtCommon::Game::ResetMetadata(false); + QtCommon::system->GetFileSystemController().CreateFactories(*QtCommon::vfs); + PopulateAsync(UISettings::values.game_dirs); + } +} + +void GameListModel::ResetExternalWatcher() { + auto watch_dirs = external_watcher->directories(); + if (!watch_dirs.isEmpty()) { + external_watcher->removePaths(watch_dirs); + } + + for (const std::string& dir : Settings::values.external_content_dirs) { + external_watcher->addPath(QString::fromStdString(dir)); + } +} + +void GameListModel::OnUpdateThemedIcons() { + for (int i = 0; i < invisibleRootItem()->rowCount(); i++) { + QStandardItem* child = invisibleRootItem()->child(i); + + const int icon_size = UISettings::values.folder_icon_size.GetValue(); + + switch (child->data(GameListItem::TypeRole).value<GameListItemType>()) { + case GameListItemType::SdmcDir: + child->setData( + QIcon::fromTheme(QStringLiteral("sd_card")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + case GameListItemType::UserNandDir: + case GameListItemType::SysNandDir: + child->setData( + QIcon::fromTheme(QStringLiteral("chip")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + case GameListItemType::CustomDir: { + const UISettings::GameDir& game_dir = + UISettings::values.game_dirs[child->data(GameListDir::GameDirRole).toInt()]; + const QString icon_name = QFileInfo::exists(QString::fromStdString(game_dir.path)) + ? QStringLiteral("folder") + : QStringLiteral("bad_folder"); + child->setData( + QIcon::fromTheme(icon_name).pixmap(icon_size).scaled( + icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + } + case GameListItemType::AddDir: + child->setData( + QIcon::fromTheme(QStringLiteral("list-add")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + case GameListItemType::Favorites: + child->setData( + QIcon::fromTheme(QStringLiteral("star")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + default: + break; + } + } +} + +void GameListModel::RetranslateUI() { + setHeaderData(COLUMN_NAME, Qt::Horizontal, tr("Name")); + setHeaderData(COLUMN_COMPATIBILITY, Qt::Horizontal, tr("Compatibility")); + setHeaderData(COLUMN_ADD_ONS, Qt::Horizontal, tr("Add-ons")); + setHeaderData(COLUMN_FILE_TYPE, Qt::Horizontal, tr("File type")); + setHeaderData(COLUMN_SIZE, Qt::Horizontal, tr("Size")); + setHeaderData(COLUMN_PLAY_TIME, Qt::Horizontal, tr("Play time")); +} + +QFileSystemWatcher* GameListModel::GetWatcher() const { + return watcher; +} + +const CompatibilityList& GameListModel::GetCompatibilityList() const { + return compatibility_list; +} + +void GameListModel::SetFlat(bool flat) { + m_flat = flat; +} diff --git a/src/qt_common/game_list/model.h b/src/qt_common/game_list/model.h new file mode 100644 index 0000000000..d0a116beb6 --- /dev/null +++ b/src/qt_common/game_list/model.h @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include <QFileSystemWatcher> +#include <QStandardItemModel> +#include <QStringList> +#include <QVector> +#include <memory> + +#include "common/common_types.h" +#include "frontend_common/play_time_manager.h" +#include "qt_common/config/uisettings.h" +#include "yuzu/compatibility_list.h" + +namespace Core { +class System; +} + +class GameListDir; +class GameListWorker; +class QStandardItem; + +namespace FileSys { +class ManualContentProvider; +class VfsFilesystem; +} // namespace FileSys + +class GameListModel : public QStandardItemModel { + Q_OBJECT + +public: + enum Column { + COLUMN_NAME, + COLUMN_FILE_TYPE, + COLUMN_SIZE, + COLUMN_PLAY_TIME, + COLUMN_ADD_ONS, + COLUMN_COMPATIBILITY, + COLUMN_COUNT, + }; + + explicit GameListModel(std::shared_ptr<FileSys::VfsFilesystem> vfs_, + FileSys::ManualContentProvider* provider_, + const PlayTime::PlayTimeManager& play_time_manager_, + Core::System& system_, QObject* parent = nullptr); + ~GameListModel() override; + + void AddDirEntry(GameListDir* entry_items); + void AddEntry(const QList<QStandardItem*>& entry_items, GameListDir* parent); + void DonePopulating(const QStringList& watch_list); + + void PopulateAsync(QVector<UISettings::GameDir>& game_dirs); + void WorkerEvent(); + + bool IsEmpty() const; + + void ToggleFavorite(u64 program_id); + + void RefreshGameDirectory(); + void RefreshExternalContent(); + void ResetExternalWatcher(); + + void LoadCompatibilityList(); + + void OnUpdateThemedIcons(); + void RetranslateUI(); + + QFileSystemWatcher* GetWatcher() const; + + const CompatibilityList& GetCompatibilityList() const; + + void SetFlat(bool flat); + +signals: + void ShowList(bool show); + void PopulatingCompleted(const QStringList& watch_list); + void SaveConfig(); + +private: + friend class GameListWorker; + + void AddFavorite(u64 program_id); + void RemoveFavorite(u64 program_id); + + bool m_flat = false; + + std::shared_ptr<FileSys::VfsFilesystem> vfs; + FileSys::ManualContentProvider* provider; + CompatibilityList compatibility_list; + const PlayTime::PlayTimeManager& play_time_manager; + Core::System& system; + + std::unique_ptr<GameListWorker> current_worker; + QFileSystemWatcher* watcher = nullptr; + QFileSystemWatcher* external_watcher = nullptr; +}; diff --git a/src/qt_common/game_list/worker.cpp b/src/qt_common/game_list/worker.cpp new file mode 100644 index 0000000000..f1499265c6 --- /dev/null +++ b/src/qt_common/game_list/worker.cpp @@ -0,0 +1,497 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include <filesystem> +#include <memory> +#include <string> +#include <utility> +#include <vector> + +#include <QDir> +#include <QFile> +#include <QFileInfo> +#include <QSettings> + +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "common/settings.h" +#include "core/core.h" +#include "core/file_sys/card_image.h" +#include "core/file_sys/content_archive.h" +#include "core/file_sys/control_metadata.h" +#include "core/file_sys/fs_filesystem.h" +#include "core/file_sys/nca_metadata.h" +#include "core/file_sys/patch_manager.h" +#include "core/file_sys/registered_cache.h" +#include "core/file_sys/submission_package.h" +#include "core/loader/loader.h" + +#include "qt_common/config/uisettings.h" +#include "qt_common/qt_common.h" + +#include "yuzu/compatibility_list.h" +#include "qt_common/game_list/game_list_p.h" + +#include "qt_common/game_list/worker.h" +#include "qt_common/game_list/model.h" + +namespace { + +QString GetGameListCachedObject(const std::string& filename, const std::string& ext, + const std::function<QString()>& generator) { + if (!UISettings::values.cache_game_list || filename == "0000000000000000") { + return generator(); + } + + const auto path = + Common::FS::PathToUTF8String(Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / + "game_list" / fmt::format("{}.{}", filename, ext)); + + void(Common::FS::CreateParentDirs(path)); + + if (!Common::FS::Exists(path)) { + const auto str = generator(); + + QFile file{QString::fromStdString(path)}; + if (file.open(QFile::WriteOnly)) { + file.write(str.toUtf8()); + } + + return str; + } + + QFile file{QString::fromStdString(path)}; + if (file.open(QFile::ReadOnly)) { + return QString::fromUtf8(file.readAll()); + } + + return generator(); +} + +std::pair<std::vector<u8>, std::string> GetGameListCachedObject( + const std::string& filename, const std::string& ext, + const std::function<std::pair<std::vector<u8>, std::string>()>& generator) { + if (!UISettings::values.cache_game_list || filename == "0000000000000000") { + return generator(); + } + + const auto game_list_dir = + Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "game_list"; + const auto jpeg_name = fmt::format("{}.jpeg", filename); + const auto app_name = fmt::format("{}.appname.txt", filename); + + const auto path1 = Common::FS::PathToUTF8String(game_list_dir / jpeg_name); + const auto path2 = Common::FS::PathToUTF8String(game_list_dir / app_name); + + void(Common::FS::CreateParentDirs(path1)); + + if (!Common::FS::Exists(path1) || !Common::FS::Exists(path2)) { + const auto [icon, nacp] = generator(); + + QFile file1{QString::fromStdString(path1)}; + if (!file1.open(QFile::WriteOnly)) { + LOG_ERROR(Frontend, "Failed to open cache file."); + return generator(); + } + + if (!file1.resize(icon.size())) { + LOG_ERROR(Frontend, "Failed to resize cache file to necessary size."); + return generator(); + } + + if (file1.write(reinterpret_cast<const char*>(icon.data()), icon.size()) != + s64(icon.size())) { + LOG_ERROR(Frontend, "Failed to write data to cache file."); + return generator(); + } + + QFile file2{QString::fromStdString(path2)}; + if (file2.open(QFile::WriteOnly)) { + file2.write(nacp.data(), nacp.size()); + } + + return std::make_pair(icon, nacp); + } + + QFile file1(QString::fromStdString(path1)); + QFile file2(QString::fromStdString(path2)); + + if (!file1.open(QFile::ReadOnly)) { + LOG_ERROR(Frontend, "Failed to open cache file for reading."); + return generator(); + } + + if (!file2.open(QFile::ReadOnly)) { + LOG_ERROR(Frontend, "Failed to open cache file for reading."); + return generator(); + } + + std::vector<u8> vec(file1.size()); + if (file1.read(reinterpret_cast<char*>(vec.data()), vec.size()) != + static_cast<s64>(vec.size())) { + return generator(); + } + + const auto data = file2.readAll(); + return std::make_pair(vec, data.toStdString()); +} + +void GetMetadataFromControlNCA(const FileSys::PatchManager& patch_manager, const FileSys::NCA& nca, + std::vector<u8>& icon, std::string& name) { + std::tie(icon, name) = GetGameListCachedObject( + fmt::format("{:016X}", patch_manager.GetTitleID()), {}, [&patch_manager, &nca] { + const auto [nacp, icon_f] = patch_manager.ParseControlNCA(nca); + return std::make_pair(icon_f->ReadAllBytes(), nacp->GetApplicationName()); + }); +} + +bool HasSupportedFileExtension(const std::string& file_name) { + const QFileInfo file = QFileInfo(QString::fromStdString(file_name)); + return QtCommon::supported_file_extensions.contains(file.suffix(), Qt::CaseInsensitive); +} + +bool IsExtractedNCAMain(const std::string& file_name) { + return QFileInfo(QString::fromStdString(file_name)).fileName() == QStringLiteral("main"); +} + +QString FormatGameName(const std::string& physical_name) { + const QString physical_name_as_qstring = QString::fromStdString(physical_name); + const QFileInfo file_info(physical_name_as_qstring); + + if (IsExtractedNCAMain(physical_name)) { + return file_info.dir().path(); + } + + return physical_name_as_qstring; +} + +QString FormatPatchNameVersions(const FileSys::PatchManager& patch_manager, + Loader::AppLoader& loader, bool updatable = true) { + QString out; + FileSys::VirtualFile update_raw; + loader.ReadUpdateRaw(update_raw); + for (const auto& patch : patch_manager.GetPatches(update_raw)) { + const bool is_update = patch.name == "Update"; + if (!updatable && is_update) { + continue; + } + + const QString type = + QString::fromStdString(patch.enabled ? patch.name : "[D] " + patch.name); + + if (patch.version.empty()) { + out.append(QStringLiteral("%1\n").arg(type)); + } else { + auto ver = patch.version; + + // Display container name for packed updates + if (is_update && ver == "PACKED") { + ver = Loader::GetFileTypeString(loader.GetFileType()); + } + + out.append(QStringLiteral("%1 (%2)\n").arg(type, QString::fromStdString(ver))); + } + } + + out.chop(1); + return out; +} + +QList<QStandardItem*> MakeGameListEntry(const std::string& path, const std::string& name, + const std::size_t size, const std::vector<u8>& icon, + Loader::AppLoader& loader, u64 program_id, + const CompatibilityList& compatibility_list, + const PlayTime::PlayTimeManager& play_time_manager, + const FileSys::PatchManager& patch) { + auto const it = FindMatchingCompatibilityEntry(compatibility_list, program_id); + // The game list uses 99 as compatibility number for untested games + QString compatibility = + it != compatibility_list.end() ? it->second.first : QStringLiteral("99"); + + auto const file_type = loader.GetFileType(); + auto const file_type_string = QString::fromStdString(Loader::GetFileTypeString(file_type)); + + QString patch_versions = GetGameListCachedObject( + fmt::format("{:016X}", patch.GetTitleID()), "pv.txt", [&patch, &loader] { + return FormatPatchNameVersions(patch, loader, loader.IsRomFSUpdatable()); + }); + + u64 play_time = play_time_manager.GetPlayTime(program_id); + return QList<QStandardItem*>{ + new GameListItemPath(FormatGameName(path), icon, QString::fromStdString(name), + file_type_string, program_id, play_time, patch_versions), + new GameListItem(file_type_string), + new GameListItemSize(size), + new GameListItemPlayTime(play_time), + new GameListItem(patch_versions), + new GameListItemCompat(compatibility), + }; +} +} // Anonymous namespace + +GameListWorker::GameListWorker(FileSys::VirtualFilesystem vfs_, + FileSys::ManualContentProvider* provider_, + QVector<UISettings::GameDir>& game_dirs_, + const CompatibilityList& compatibility_list_, + const PlayTime::PlayTimeManager& play_time_manager_, + Core::System& system_) + : vfs{std::move(vfs_)}, provider{provider_}, game_dirs{game_dirs_}, + compatibility_list{compatibility_list_}, play_time_manager{play_time_manager_}, + system{system_} { + // We want the game list to manage our lifetime. + setAutoDelete(false); +} + +GameListWorker::~GameListWorker() { + this->disconnect(); + stop_requested.store(true); + processing_completed.Wait(); +} + +void GameListWorker::ProcessEvents(GameListModel* model) { + while (true) { + std::function<void(GameListModel*)> func; + { + // Lock queue to protect concurrent modification. + std::scoped_lock lk(lock); + + // If we can't pop a function, return. + if (queued_events.empty()) { + return; + } + + // Pop a function. + func = std::move(queued_events.back()); + queued_events.pop_back(); + } + + // Run the function. + func(model); + } +} + +template <typename F> +void GameListWorker::RecordEvent(F&& func) { + { + // Lock queue to protect concurrent modification. + std::scoped_lock lk(lock); + + // Add the function into the front of the queue. + queued_events.emplace_front(std::move(func)); + } + + // Data now available. + emit DataAvailable(); +} + +void GameListWorker::AddTitlesToGameList(GameListDir* parent_dir) { + using namespace FileSys; + + const auto& cache = system.GetContentProviderUnion(); + + auto installed_games = cache.ListEntriesFilterOrigin(std::nullopt, TitleType::Application, + ContentRecordType::Program); + + if (parent_dir->type() == static_cast<int>(GameListItemType::SdmcDir)) { + installed_games = cache.ListEntriesFilterOrigin( + ContentProviderUnionSlot::SDMC, TitleType::Application, ContentRecordType::Program); + } else if (parent_dir->type() == static_cast<int>(GameListItemType::UserNandDir)) { + installed_games = cache.ListEntriesFilterOrigin( + ContentProviderUnionSlot::UserNAND, TitleType::Application, ContentRecordType::Program); + } else if (parent_dir->type() == static_cast<int>(GameListItemType::SysNandDir)) { + installed_games = cache.ListEntriesFilterOrigin( + ContentProviderUnionSlot::SysNAND, TitleType::Application, ContentRecordType::Program); + } + + for (const auto& [slot, game] : installed_games) { + if (slot == ContentProviderUnionSlot::FrontendManual) { + continue; + } + + const auto file = cache.GetEntryUnparsed(game.title_id, game.type); + std::unique_ptr<Loader::AppLoader> loader = Loader::GetLoader(system, file); + if (!loader) { + continue; + } + + std::vector<u8> icon; + std::string name; + u64 program_id = 0; + const auto result = loader->ReadProgramId(program_id); + + if (result != Loader::ResultStatus::Success) { + continue; + } + + const PatchManager patch{program_id, system.GetFileSystemController(), + system.GetContentProvider()}; + LOG_INFO(Frontend, "PatchManager initiated for id {:X}", program_id); + const auto control = cache.GetEntry(game.title_id, ContentRecordType::Control); + if (control != nullptr) { + GetMetadataFromControlNCA(patch, *control, icon, name); + } + + auto entry = MakeGameListEntry(file->GetFullPath(), name, file->GetSize(), icon, *loader, + program_id, compatibility_list, play_time_manager, patch); + RecordEvent([=](GameListModel* model) { model->AddEntry(entry, parent_dir); }); + } +} + +void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_path, bool deep_scan, + GameListDir* parent_dir) { + const auto callback = [this, target, parent_dir](const std::filesystem::path& path) -> bool { + if (stop_requested) { + // Breaks the callback loop. + return false; + } + + const auto physical_name = Common::FS::PathToUTF8String(path); + const auto is_dir = Common::FS::IsDir(path); + + if (!is_dir && + (HasSupportedFileExtension(physical_name) || IsExtractedNCAMain(physical_name))) { + const auto file = vfs->OpenFile(physical_name, FileSys::OpenMode::Read); + if (!file) { + return true; + } + + auto loader = Loader::GetLoader(system, file); + if (!loader) { + return true; + } + + const auto file_type = loader->GetFileType(); + if (file_type == Loader::FileType::Unknown || file_type == Loader::FileType::Error) { + return true; + } + + if (target == ScanTarget::PopulateGameList && + (file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP) && + !Loader::IsBootableGameContainer(file, file_type)) { + return true; + } + + u64 program_id = 0; + const auto res2 = loader->ReadProgramId(program_id); + + if (target == ScanTarget::FillManualContentProvider) { + if (res2 == Loader::ResultStatus::Success && file_type == Loader::FileType::NCA) { + provider->AddEntry(FileSys::TitleType::Application, + FileSys::GetCRTypeFromNCAType(FileSys::NCA{file}.GetType()), + program_id, file); + } else if (Settings::values.ext_content_from_game_dirs.GetValue() && + (file_type == Loader::FileType::XCI || + file_type == Loader::FileType::NSP)) { + void(provider->AddEntriesFromContainer(file)); + } + } else { + std::vector<u64> program_ids; + loader->ReadProgramIds(program_ids); + + if (res2 == Loader::ResultStatus::Success && program_ids.size() > 1 && + (file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP)) { + for (const auto id : program_ids) { + // dravee suggested this, only viable way to + // not show sub-games in qlaunch for now. + if ((id & 0xFFF) != 0) { + continue; + } + loader = Loader::GetLoader(system, file, id); + if (!loader) { + continue; + } + + std::vector<u8> icon; + [[maybe_unused]] const auto res1 = loader->ReadIcon(icon); + + std::string name = " "; + [[maybe_unused]] const auto res3 = loader->ReadTitle(name); + + const FileSys::PatchManager patch{id, system.GetFileSystemController(), + system.GetContentProvider()}; + + auto entry = MakeGameListEntry( + physical_name, name, Common::FS::GetSize(physical_name), icon, *loader, + id, compatibility_list, play_time_manager, patch); + + RecordEvent( + [=](GameListModel* model) { model->AddEntry(entry, parent_dir); }); + } + } else { + std::vector<u8> icon; + [[maybe_unused]] const auto res1 = loader->ReadIcon(icon); + + std::string name = " "; + [[maybe_unused]] const auto res3 = loader->ReadTitle(name); + + const FileSys::PatchManager patch{program_id, system.GetFileSystemController(), + system.GetContentProvider()}; + + auto entry = MakeGameListEntry( + physical_name, name, Common::FS::GetSize(physical_name), icon, *loader, + program_id, compatibility_list, play_time_manager, patch); + + RecordEvent( + [=](GameListModel* model) { model->AddEntry(entry, parent_dir); }); + } + } + } else if (is_dir) { + watch_list.append(QString::fromStdString(physical_name)); + } + + return true; + }; + + if (deep_scan) { + Common::FS::IterateDirEntriesRecursively(dir_path, callback, + Common::FS::DirEntryFilter::All); + } else { + Common::FS::IterateDirEntries(dir_path, callback, Common::FS::DirEntryFilter::File); + } +} + +void GameListWorker::run() { + watch_list.clear(); + provider->ClearAllEntries(); + + const auto DirEntryReady = [&](GameListDir* game_list_dir) { + RecordEvent([=](GameListModel* model) { model->AddDirEntry(game_list_dir); }); + }; + + for (UISettings::GameDir& game_dir : game_dirs) { + if (stop_requested) { + break; + } + + if (game_dir.path == std::string("SDMC")) { + auto* const game_list_dir = new GameListDir(game_dir, GameListItemType::SdmcDir); + DirEntryReady(game_list_dir); + AddTitlesToGameList(game_list_dir); + } else if (game_dir.path == std::string("UserNAND")) { + auto* const game_list_dir = new GameListDir(game_dir, GameListItemType::UserNandDir); + DirEntryReady(game_list_dir); + AddTitlesToGameList(game_list_dir); + } else if (game_dir.path == std::string("SysNAND")) { + auto* const game_list_dir = new GameListDir(game_dir, GameListItemType::SysNandDir); + DirEntryReady(game_list_dir); + AddTitlesToGameList(game_list_dir); + } else { + const QString qpath = QString::fromStdString(game_dir.path); + if (QDir(qpath).exists()) { + watch_list.append(qpath); + } + auto* const game_list_dir = new GameListDir(game_dir); + DirEntryReady(game_list_dir); + ScanFileSystem(ScanTarget::FillManualContentProvider, game_dir.path, game_dir.deep_scan, + game_list_dir); + ScanFileSystem(ScanTarget::PopulateGameList, game_dir.path, game_dir.deep_scan, + game_list_dir); + } + } + + RecordEvent([this](GameListModel* model) { model->DonePopulating(watch_list); }); + processing_completed.Set(); +} diff --git a/src/qt_common/game_list/worker.h b/src/qt_common/game_list/worker.h new file mode 100644 index 0000000000..c11741c5f5 --- /dev/null +++ b/src/qt_common/game_list/worker.h @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include <atomic> +#include <deque> +#include <memory> +#include <string> + +#include <QList> +#include <QObject> +#include <QRunnable> +#include <QString> + +#include "common/thread.h" +#include "core/file_sys/registered_cache.h" +#include "frontend_common/play_time_manager.h" +#include "qt_common/config/uisettings.h" +#include "yuzu/compatibility_list.h" + +namespace Core { +class System; +} + +class GameListDir; +class GameListModel; +class QStandardItem; + +namespace FileSys { +class NCA; +class VfsFilesystem; +} // namespace FileSys + +/** + * Asynchronous worker object for populating the game list. + * Communicates with other threads through Qt's signal/slot system. + */ +class GameListWorker : public QObject, public QRunnable { + Q_OBJECT + +public: + explicit GameListWorker(std::shared_ptr<FileSys::VfsFilesystem> vfs_, + FileSys::ManualContentProvider* provider_, + QVector<UISettings::GameDir>& game_dirs_, + const CompatibilityList& compatibility_list_, + const PlayTime::PlayTimeManager& play_time_manager_, + Core::System& system_); + ~GameListWorker() override; + + /// Starts the processing of directory tree information. + void run() override; + +public: + /** + * Synchronously processes any events queued by the worker. + * + * AddDirEntry is called on the model for every discovered directory. + * AddEntry is called on the model for every discovered program. + * DonePopulating is called on the model when processing completes. + */ + void ProcessEvents(GameListModel* model); + +signals: + void DataAvailable(); + +private: + template <typename F> + void RecordEvent(F&& func); + +private: + void AddTitlesToGameList(GameListDir* parent_dir); + + enum class ScanTarget { + FillManualContentProvider, + PopulateGameList, + }; + + void ScanFileSystem(ScanTarget target, const std::string& dir_path, bool deep_scan, + GameListDir* parent_dir); + + std::shared_ptr<FileSys::VfsFilesystem> vfs; + FileSys::ManualContentProvider* provider; + QVector<UISettings::GameDir>& game_dirs; + const CompatibilityList& compatibility_list; + const PlayTime::PlayTimeManager& play_time_manager; + + QStringList watch_list; + + std::mutex lock; + std::condition_variable cv; + std::deque<std::function<void(GameListModel*)>> queued_events; + std::atomic_bool stop_requested = false; + Common::Event processing_completed; + + Core::System& system; +}; |
