[desktop] Basic grid view implementation (#3479)

Closes #3441

Basic impl of a grid view on the game list. The ideal solution here
would be to use QSortFilterProxyModel and abstract the game list model
out to a QStandardItemModel, but that is too much effort for me rn.
Adapted the "card" design from QML, can 1000% be improved but QPainter
is just such a pain to deal with. Implanting a Qt Quick scene into there
would legitimately be easier.

Anyways, margins and text sizes lgtm at all sizes, though please give
feedback on both that and the general card design.

Future TODOs:
- [ ] Auto size mode
- [ ] Refactor to use models

Signed-off-by: crueter <crueter@eden-emu.dev>
Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3479
This commit is contained in:
crueter 2026-02-06 19:51:01 +01:00
parent 69aff83ef4
commit b9e052b3a7
No known key found for this signature in database
GPG key ID: 425ACD2D4830EBC6
19 changed files with 517 additions and 122 deletions

104
src/yuzu/game/game_card.cpp Normal file
View file

@ -0,0 +1,104 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#include <QPainter>
#include "game_card.h"
#include "qt_common/config/uisettings.h"
GameCard::GameCard(QObject* parent) : QStyledItemDelegate{parent} {
setObjectName("GameCard");
}
void GameCard::paint(QPainter* painter, const QStyleOptionViewItem& option,
const QModelIndex& index) const {
if (!index.isValid())
return;
painter->save();
painter->setRenderHint(QPainter::Antialiasing);
// padding
QRect cardRect = option.rect.adjusted(4, 4, -4, -4);
// colors
QPalette palette = option.palette;
QColor backgroundColor = palette.window().color();
QColor borderColor = palette.dark().color();
QColor textColor = palette.text().color();
// if it's selected add a blue background
if (option.state & QStyle::State_Selected) {
backgroundColor = palette.highlight().color();
borderColor = palette.highlight().color().lighter(150);
textColor = palette.highlightedText().color();
} else if (option.state & QStyle::State_MouseOver) {
backgroundColor = backgroundColor.lighter(110);
}
// bg
painter->setBrush(backgroundColor);
painter->setPen(QPen(borderColor, 1));
painter->drawRoundedRect(cardRect, 10, 10);
static constexpr const int padding = 10;
// icon
int _iconsize = UISettings::values.game_icon_size.GetValue();
QSize iconSize(_iconsize, _iconsize);
QPixmap iconPixmap = index.data(Qt::DecorationRole).value<QPixmap>();
QRect iconRect;
if (!iconPixmap.isNull()) {
QSize scaledSize = iconPixmap.size();
scaledSize.scale(iconSize, Qt::KeepAspectRatio);
int x = cardRect.left() + (cardRect.width() - scaledSize.width()) / 2;
int y = cardRect.top() + padding;
iconRect = QRect(x, y, scaledSize.width(), scaledSize.height());
painter->setRenderHint(QPainter::SmoothPixmapTransform, true);
painter->drawPixmap(iconRect, iconPixmap);
} else {
// if there is no icon just draw a blank rect
iconRect = QRect(cardRect.left() + padding,
cardRect.top() + padding,
_iconsize, _iconsize);
}
// if "none" is selected, pretend there's a
_iconsize = _iconsize ? _iconsize : 96;
// padding + text
QRect textRect = cardRect;
textRect.setTop(iconRect.bottom() + 8);
textRect.adjust(padding, 0, -padding, -padding);
// We are already crammed on space, ignore the row 2
QString title = index.data(Qt::DisplayRole).toString();
title = title.split(QLatin1Char('\n')).first();
// now draw text
painter->setPen(textColor);
QFont font = option.font;
font.setBold(true);
// TODO(crueter): fix this abysmal scaling
// If "none" is selected, then default to 8.5 point font.
font.setPointSize(1 + std::max(7.0, _iconsize ? std::sqrt(_iconsize * 0.6) : 7.5));
// TODO(crueter): elide mode
painter->setFont(font);
painter->drawText(textRect, Qt::AlignHCenter | Qt::AlignTop | Qt::TextWordWrap, title);
painter->restore();
}
QSize GameCard::sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const {
return m_size;
}
void GameCard::setSize(const QSize& newSize) {
m_size = newSize;
}

27
src/yuzu/game/game_card.h Normal file
View file

@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QStyledItemDelegate>
/**
* A stylized "card"-like delegate for the game grid view.
* Adapted from QML
*/
class GameCard : public QStyledItemDelegate {
Q_OBJECT
public:
explicit GameCard(QObject* parent = nullptr);
void paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
void setSize(const QSize& newSize);
private:
QSize m_size;
};

1282
src/yuzu/game/game_list.cpp Normal file

File diff suppressed because it is too large Load diff

222
src/yuzu/game/game_list.h Normal file
View file

@ -0,0 +1,222 @@
// 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 <QFileSystemWatcher>
#include <QLabel>
#include <QLineEdit>
#include <QList>
#include <QPushButton>
#include <QStandardItemModel>
#include <QString>
#include <QTreeView>
#include <QVBoxLayout>
#include <QVector>
#include <QWidget>
#include <qabstractitemview.h>
#include "common/common_types.h"
#include "core/core.h"
#include "qt_common/config/uisettings.h"
#include "qt_common/util/game.h"
#include "yuzu/compatibility_list.h"
#include "frontend_common/play_time_manager.h"
class QVariantAnimation;
class QListView;
class GameCard;
namespace Core {
class System;
}
class ControllerNavigation;
class GameListWorker;
class GameListSearchField;
class GameListDir;
class MainWindow;
enum class AmLaunchType;
enum class StartGameType;
namespace FileSys {
class ManualContentProvider;
class VfsFilesystem;
} // namespace FileSys
enum class GameListOpenTarget {
SaveData,
ModData,
};
enum class DumpRomFSTarget {
Normal,
SDMC,
};
class GameList : public QWidget {
Q_OBJECT
public:
enum {
COLUMN_NAME,
COLUMN_FILE_TYPE,
COLUMN_SIZE,
COLUMN_PLAY_TIME,
COLUMN_ADD_ONS,
COLUMN_COMPATIBILITY,
COLUMN_COUNT, // Number of columns
};
explicit GameList(std::shared_ptr<FileSys::VfsFilesystem> vfs_,
FileSys::ManualContentProvider* provider_,
PlayTime::PlayTimeManager& play_time_manager_, Core::System& system_,
MainWindow* parent = nullptr);
~GameList() override;
QString GetLastFilterResultItem() const;
void ClearFilter();
void SetFilterFocus();
void SetFilterVisible(bool visibility);
bool IsEmpty() const;
void LoadCompatibilityList();
void PopulateAsync(QVector<UISettings::GameDir>& game_dirs);
void SaveInterfaceLayout();
void LoadInterfaceLayout();
QStandardItemModel* GetModel() const;
/// Disables events from the emulated controller
void UnloadController();
static const QStringList supported_file_extensions;
bool IsTreeMode();
void ResetViewMode();
public slots:
void RefreshGameDirectory();
void RefreshExternalContent();
void ResetExternalWatcher();
signals:
void BootGame(const QString& game_path, StartGameType type);
void GameChosen(const QString& game_path, const u64 title_id = 0);
void OpenFolderRequested(u64 program_id, GameListOpenTarget target,
const std::string& game_path);
void OpenTransferableShaderCacheRequested(u64 program_id);
void RemoveInstalledEntryRequested(u64 program_id, QtCommon::Game::InstalledEntryType type);
void RemoveFileRequested(u64 program_id, QtCommon::Game::GameListRemoveTarget target,
const std::string& game_path);
void RemovePlayTimeRequested(u64 program_id);
void SetPlayTimeRequested(u64 program_id);
void DumpRomFSRequested(u64 program_id, const std::string& game_path, DumpRomFSTarget target);
void VerifyIntegrityRequested(const std::string& game_path);
void CopyTIDRequested(u64 program_id);
void CreateShortcut(u64 program_id, const std::string& game_path,
const QtCommon::Game::ShortcutTarget target);
void NavigateToGamedbEntryRequested(u64 program_id,
const CompatibilityList& compatibility_list);
void OpenPerGameGeneralRequested(const std::string& file);
void LinkToRyujinxRequested(const u64 &program_id);
void OpenDirectory(const QString& directory);
void AddDirectory();
void ShowList(bool show);
void PopulatingCompleted();
void SaveConfig();
private slots:
void OnItemExpanded(const QModelIndex& item);
void OnTextChanged(const QString& new_text);
void OnFilterCloseClicked();
void OnUpdateThemedIcons();
void UpdateIconSize();
private:
friend class GameListWorker;
void WorkerEvent();
void AddDirEntry(GameListDir* entry_items);
void AddEntry(const QList<QStandardItem*>& entry_items, GameListDir* parent);
void DonePopulating(const QStringList& watch_list);
private:
void ValidateEntry(const QModelIndex& item);
void ToggleFavorite(u64 program_id);
void AddFavorite(u64 program_id);
void RemoveFavorite(u64 program_id);
void PopupContextMenu(const QPoint& menu_location);
void AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path);
void AddCustomDirPopup(QMenu& context_menu, QModelIndex selected);
void AddPermDirPopup(QMenu& context_menu, QModelIndex selected);
void AddFavoritesPopup(QMenu& context_menu);
void changeEvent(QEvent*) override;
void RetranslateUI();
std::shared_ptr<FileSys::VfsFilesystem> vfs;
FileSys::ManualContentProvider* provider;
GameListSearchField* search_field;
MainWindow* main_window = nullptr;
QVBoxLayout* layout = nullptr;
QTreeView* tree_view = nullptr;
QListView *list_view = nullptr;
GameCard *m_gameCard = nullptr;
QStandardItemModel* item_model = nullptr;
std::unique_ptr<GameListWorker> current_worker;
QFileSystemWatcher* watcher = nullptr;
QFileSystemWatcher* external_watcher = nullptr;
ControllerNavigation* controller_navigation = nullptr;
CompatibilityList compatibility_list;
QVariantAnimation* vertical_scroll = nullptr;
QVariantAnimation* horizontal_scroll = nullptr;
int vertical_scroll_target = 0;
int horizontal_scroll_target = 0;
void SetupScrollAnimation();
bool eventFilter(QObject* obj, QEvent* event) override;
friend class GameListSearchField;
const PlayTime::PlayTimeManager& play_time_manager;
Core::System& system;
bool m_isTreeMode = true;
QAbstractItemView *m_currentView = tree_view;
};
class GameListPlaceholder : public QWidget {
Q_OBJECT
public:
explicit GameListPlaceholder(MainWindow* parent = nullptr);
~GameListPlaceholder();
signals:
void AddDirectory();
private slots:
void onUpdateThemedIcons();
protected:
void mouseDoubleClickEvent(QMouseEvent* event) override;
private:
void changeEvent(QEvent* event) override;
void RetranslateUI();
QVBoxLayout* layout = nullptr;
QLabel* image = nullptr;
QLabel* text = nullptr;
};

428
src/yuzu/game/game_list_p.h Normal file
View file

@ -0,0 +1,428 @@
// 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 <QStandardItem>
#include <QString>
#include <QWidget>
#include "common/common_types.h"
#include "common/logging/log.h"
#include "common/string_util.h"
#include "frontend_common/play_time_manager.h"
#include "qt_common/config/uisettings.h"
#include "yuzu/util/util.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) {
setData(type(), TypeRole);
setData(game_path, FullPathRole);
setData(game_name, TitleRole);
setData(qulonglong(program_id), ProgramIdRole);
setData(game_type, FileTypeRole);
setData(QString::fromStdString(PlayTime::PlayTimeManager::GetReadablePlayTime(play_time)),
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(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(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;
}
};
class GameList;
class QHBoxLayout;
class QTreeView;
class QLabel;
class QLineEdit;
class QToolButton;
class GameListSearchField : public QWidget {
Q_OBJECT
public:
explicit GameListSearchField(GameList* parent = nullptr);
QString filterText() const;
void setFilterResult(int visible_, int total_);
void clear();
void setFocus();
private:
void changeEvent(QEvent*) override;
void RetranslateUI();
class KeyReleaseEater : public QObject {
public:
explicit KeyReleaseEater(GameList* gamelist_, QObject* parent = nullptr);
private:
GameList* gamelist = nullptr;
QString edit_filter_text_old;
protected:
// EventFilter in order to process systemkeys while editing the searchfield
bool eventFilter(QObject* obj, QEvent* event) override;
};
int visible;
int total;
QHBoxLayout* layout_filter = nullptr;
QTreeView* tree_view = nullptr;
QLabel* label_filter = nullptr;
QLineEdit* edit_filter = nullptr;
QLabel* label_filter_result = nullptr;
QToolButton* button_filter_close = nullptr;
};

View file

@ -0,0 +1,518 @@
// 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 <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 "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 "yuzu/compatibility_list.h"
#include "yuzu/game/game_list.h"
#include "yuzu/game/game_list_p.h"
#include "yuzu/game/game_list_worker.h"
#include "qt_common/config/uisettings.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 GameList::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),
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(GameList* game_list) {
while (true) {
std::function<void(GameList*)> 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(game_list);
}
}
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([=](GameList* game_list) { game_list->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;
}
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 (res2 == Loader::ResultStatus::Success &&
(file_type == Loader::FileType::XCI ||
file_type == Loader::FileType::NSP)) {
const auto nsp = file_type == Loader::FileType::NSP
? std::make_shared<FileSys::NSP>(file)
: FileSys::XCI{file}.GetSecurePartitionNSP();
for (const auto& title : nsp->GetNCAs()) {
for (const auto& entry : title.second) {
provider->AddEntry(entry.first.first, entry.first.second, title.first,
entry.second->GetBaseFile());
}
}
}
} 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(
[=](GameList* game_list) { game_list->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(
[=](GameList* game_list) { game_list->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([=](GameList* game_list) { game_list->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](GameList* game_list) { game_list->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 "qt_common/config/uisettings.h"
#include "yuzu/compatibility_list.h"
#include "frontend_common/play_time_manager.h"
namespace Core {
class System;
}
class GameList;
class GameListDir;
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 game list for every discovered directory.
* AddEntry is called on the game list for every discovered program.
* DonePopulating is called on the game list when processing completes.
*/
void ProcessEvents(GameList* game_list);
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(GameList*)>> queued_events;
std::atomic_bool stop_requested = false;
Common::Event processing_completed;
Core::System& system;
};