aboutsummaryrefslogtreecommitdiff
path: root/src/qt_common
diff options
context:
space:
mode:
authorcrueter <crueter@eden-emu.dev>2026-06-02 04:08:24 +0200
committercrueter <crueter@eden-emu.dev>2026-06-02 04:08:24 +0200
commitcc8451f764979db23208dd5a55884cd62fd8b673 (patch)
tree5fe5dc8e28322f897492639e7c895d226f9bcdc5 /src/qt_common
parent27189f39d2bf78c924475ac6ea2a79c3d6ec6d8a (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')
-rw-r--r--src/qt_common/CMakeLists.txt4
-rw-r--r--src/qt_common/game_list/game_list_p.h399
-rw-r--r--src/qt_common/game_list/model.cpp303
-rw-r--r--src/qt_common/game_list/model.h98
-rw-r--r--src/qt_common/game_list/worker.cpp497
-rw-r--r--src/qt_common/game_list/worker.h100
-rw-r--r--src/qt_common/qt_common.cpp17
-rw-r--r--src/qt_common/qt_common.h14
8 files changed, 1432 insertions, 0 deletions
diff --git a/src/qt_common/CMakeLists.txt b/src/qt_common/CMakeLists.txt
index a8c4e51240..8858b819d6 100644
--- a/src/qt_common/CMakeLists.txt
+++ b/src/qt_common/CMakeLists.txt
@@ -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
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;
+};
diff --git a/src/qt_common/qt_common.cpp b/src/qt_common/qt_common.cpp
index 98de0a6317..b9b8caf6e8 100644
--- a/src/qt_common/qt_common.cpp
+++ b/src/qt_common/qt_common.cpp
@@ -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
diff --git a/src/qt_common/qt_common.h b/src/qt_common/qt_common.h
index dd860ef5f7..2109774506 100644
--- a/src/qt_common/qt_common.h
+++ b/src/qt_common/qt_common.h
@@ -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