[desktop] Rework game list to use MVP architecture (#4042)
Some checks are pending
tx-src / sources (push) Waiting to run
Check Strings / check-strings (push) Waiting to run

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>
This commit is contained in:
crueter 2026-06-02 04:08:24 +02:00
parent 27189f39d2
commit cc8451f764
No known key found for this signature in database
GPG key ID: 425ACD2D4830EBC6
25 changed files with 1307 additions and 1045 deletions

View file

@ -31,6 +31,10 @@ add_library(qt_common STATIC
abstract/frontend.h abstract/frontend.cpp
abstract/progress.h abstract/progress.cpp
game_list/model.h game_list/model.cpp
game_list/worker.h game_list/worker.cpp
game_list/game_list_p.h
qt_string_lookup.h
qt_compat.h

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@
#include "common/memory_detect.h"
#include "core/hle/service/filesystem/filesystem.h"
#include "frontend_common/data_manager.h"
#include "hid_core/hid_core.h"
#include "network/network.h"
#include "qt_common.h"
@ -27,6 +28,7 @@
#include <thread>
#include <JlCompress.h>
#include <QPainter>
#if !defined(WIN32) && !defined(__APPLE__)
#include <qpa/qplatformnativeinterface.h>
@ -307,4 +309,19 @@ void SetupHID() {
system->HIDCore().ReloadInputDevices();
}
QString ReadableByteSize(qulonglong size) {
return QString::fromStdString(FrontendCommon::DataManager::ReadableBytesSize(size));
}
QPixmap CreateCirclePixmapFromColor(const QColor& color) {
QPixmap circle_pixmap(16, 16);
circle_pixmap.fill(Qt::transparent);
QPainter painter(&circle_pixmap);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(color);
painter.setBrush(color);
painter.drawEllipse({circle_pixmap.width() / 2.0, circle_pixmap.height() / 2.0}, 7.0, 7.0);
return circle_pixmap;
}
} // namespace QtCommon

View file

@ -46,6 +46,20 @@ void SetupHID();
const QString tr(const char* str);
const QString tr(const std::string& str);
// TODO: Find a better place for these
/// Convert a size in bytes into a readable format (KiB, MiB, etc.)
[[nodiscard]] QString ReadableByteSize(qulonglong size);
/**
* Creates a circle pixmap from a specified color
* @param color The color the pixmap shall have
* @return QPixmap circle pixmap
*/
[[nodiscard]] QPixmap CreateCirclePixmapFromColor(const QColor& color);
std::filesystem::path GetEdenCommand();
} // namespace QtCommon
#endif