Browse Source
[desktop] Rework game list to use MVP architecture (#4042)
[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>crueter-patch-1
No known key found for this signature in database
GPG Key ID: 425ACD2D4830EBC6
25 changed files with 1300 additions and 1038 deletions
-
4src/qt_common/CMakeLists.txt
-
53src/qt_common/game_list/game_list_p.h
-
303src/qt_common/game_list/model.cpp
-
98src/qt_common/game_list/model.h
-
23src/qt_common/game_list/worker.cpp
-
12src/qt_common/game_list/worker.h
-
17src/qt_common/qt_common.cpp
-
14src/qt_common/qt_common.h
-
26src/yuzu/CMakeLists.txt
-
3src/yuzu/configuration/configure_per_game.cpp
-
83src/yuzu/game/game_card.cpp
-
118src/yuzu/game/game_grid.cpp
-
26src/yuzu/game/game_grid.h
-
1069src/yuzu/game/game_list.cpp
-
69src/yuzu/game/game_list.h
-
173src/yuzu/game/game_tree.cpp
-
35src/yuzu/game/game_tree.h
-
124src/yuzu/game/search_field.cpp
-
52src/yuzu/game/search_field.h
-
2src/yuzu/multiplayer/chat_room.cpp
-
2src/yuzu/multiplayer/client_room.cpp
-
2src/yuzu/multiplayer/host_room.cpp
-
2src/yuzu/multiplayer/lobby.cpp
-
18src/yuzu/util/util.cpp
-
10src/yuzu/util/util.h
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
}; |
||||
@ -0,0 +1,118 @@ |
|||||
|
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
|
||||
|
#include <QScroller>
|
||||
|
#include <QScrollerProperties>
|
||||
|
|
||||
|
#include "qt_common/config/uisettings.h"
|
||||
|
#include "yuzu/game/game_card.h"
|
||||
|
#include "yuzu/game/game_grid.h"
|
||||
|
#include "qt_common/game_list/game_list_p.h"
|
||||
|
#include "qt_common/game_list/model.h"
|
||||
|
|
||||
|
GameGrid::GameGrid(QWidget* parent) : QListView{parent} { |
||||
|
m_gameCard = new GameCard(this); |
||||
|
setItemDelegate(m_gameCard); |
||||
|
|
||||
|
setViewMode(QListView::ListMode); |
||||
|
setResizeMode(QListView::Fixed); |
||||
|
setUniformItemSizes(true); |
||||
|
setSelectionMode(QAbstractItemView::SingleSelection); |
||||
|
setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); |
||||
|
setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel); |
||||
|
|
||||
|
setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); |
||||
|
|
||||
|
setEditTriggers(QAbstractItemView::NoEditTriggers); |
||||
|
setContextMenuPolicy(Qt::CustomContextMenu); |
||||
|
setGridSize(QSize(140, 160)); |
||||
|
m_gameCard->setSize(gridSize(), 0, 4); |
||||
|
|
||||
|
setSpacing(10); |
||||
|
setWordWrap(true); |
||||
|
setTextElideMode(Qt::ElideRight); |
||||
|
setFlow(QListView::LeftToRight); |
||||
|
setWrapping(true); |
||||
|
} |
||||
|
|
||||
|
void GameGrid::SetModel(GameListModel* model) { |
||||
|
QListView::setModel(model); |
||||
|
UpdateIconSize(); |
||||
|
} |
||||
|
|
||||
|
void GameGrid::ApplyFilter(const QString& edit_filter_text, GameListModel* model) { |
||||
|
int row_count = model->rowCount(); |
||||
|
|
||||
|
auto ContainsAllWords = [](const QString& haystack, const QString& userinput) { |
||||
|
const QStringList userinput_split = |
||||
|
userinput.split(QLatin1Char{' '}, Qt::SkipEmptyParts); |
||||
|
return std::all_of(userinput_split.begin(), userinput_split.end(), |
||||
|
[&haystack](const QString& s) { return haystack.contains(s); }); |
||||
|
}; |
||||
|
|
||||
|
for (int i = 0; i < row_count; ++i) { |
||||
|
QStandardItem* item = model->item(i, 0); |
||||
|
if (!item) |
||||
|
continue; |
||||
|
|
||||
|
const QString file_path = |
||||
|
item->data(GameListItemPath::FullPathRole).toString().toLower(); |
||||
|
const QString file_title = |
||||
|
item->data(GameListItemPath::TitleRole).toString().toLower(); |
||||
|
const QString file_name = file_path.mid(file_path.lastIndexOf(QLatin1Char{'/'}) + 1) + |
||||
|
QLatin1Char{' '} + file_title; |
||||
|
|
||||
|
if (edit_filter_text.isEmpty() || ContainsAllWords(file_name, edit_filter_text)) { |
||||
|
setRowHidden(i, false); |
||||
|
} else { |
||||
|
setRowHidden(i, true); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void GameGrid::UpdateIconSize() { |
||||
|
const u32 icon_size = UISettings::values.game_icon_size.GetValue(); |
||||
|
|
||||
|
int heightMargin = 0; |
||||
|
int widthMargin = 80; |
||||
|
|
||||
|
if (UISettings::values.show_game_name) { |
||||
|
switch (icon_size) { |
||||
|
case 128: |
||||
|
heightMargin = 65; |
||||
|
break; |
||||
|
case 0: |
||||
|
widthMargin = 120; |
||||
|
heightMargin = 120; |
||||
|
break; |
||||
|
case 64: |
||||
|
heightMargin = 77; |
||||
|
break; |
||||
|
case 32: |
||||
|
case 256: |
||||
|
heightMargin = 81; |
||||
|
break; |
||||
|
} |
||||
|
} else { |
||||
|
widthMargin = 24; |
||||
|
heightMargin = 24; |
||||
|
} |
||||
|
|
||||
|
const int view_width = viewport()->width(); |
||||
|
|
||||
|
const double spacing = 0.01; |
||||
|
const int min_item_width = icon_size + widthMargin; |
||||
|
|
||||
|
int columns = std::max(1, (view_width - 16) / min_item_width); |
||||
|
int stretched_width = ((view_width) - (spacing * (columns - 1))) / columns; |
||||
|
|
||||
|
QSize grid_size(stretched_width, icon_size + heightMargin); |
||||
|
if (gridSize() != grid_size) { |
||||
|
setUpdatesEnabled(false); |
||||
|
|
||||
|
setGridSize(grid_size); |
||||
|
m_gameCard->setSize(grid_size, stretched_width - min_item_width, columns); |
||||
|
|
||||
|
setUpdatesEnabled(true); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project |
||||
|
// SPDX-License-Identifier: GPL-3.0-or-later |
||||
|
|
||||
|
#pragma once |
||||
|
|
||||
|
#include <QListView> |
||||
|
#include <QString> |
||||
|
|
||||
|
#include "common/common_types.h" |
||||
|
|
||||
|
class GameCard; |
||||
|
class GameListModel; |
||||
|
|
||||
|
class GameGrid : public QListView { |
||||
|
Q_OBJECT |
||||
|
|
||||
|
public: |
||||
|
explicit GameGrid(QWidget* parent = nullptr); |
||||
|
|
||||
|
void SetModel(GameListModel* model); |
||||
|
void ApplyFilter(const QString& edit_filter_text, GameListModel* model); |
||||
|
void UpdateIconSize(); |
||||
|
|
||||
|
private: |
||||
|
GameCard* m_gameCard = nullptr; |
||||
|
}; |
||||
1069
src/yuzu/game/game_list.cpp
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,173 @@ |
|||||
|
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
|
||||
|
#include <QApplication>
|
||||
|
#include <QHeaderView>
|
||||
|
#include <QScroller>
|
||||
|
#include <QScrollerProperties>
|
||||
|
|
||||
|
#include "qt_common/config/uisettings.h"
|
||||
|
#include "qt_common/game_list/game_list_p.h"
|
||||
|
#include "yuzu/game/game_tree.h"
|
||||
|
#include "qt_common/game_list/model.h"
|
||||
|
|
||||
|
GameTree::GameTree(QWidget* parent) : QTreeView{parent} { |
||||
|
setAlternatingRowColors(true); |
||||
|
setSelectionMode(QHeaderView::SingleSelection); |
||||
|
setSelectionBehavior(QHeaderView::SelectRows); |
||||
|
setVerticalScrollMode(QHeaderView::ScrollPerPixel); |
||||
|
setHorizontalScrollMode(QHeaderView::ScrollPerPixel); |
||||
|
setSortingEnabled(true); |
||||
|
setEditTriggers(QHeaderView::NoEditTriggers); |
||||
|
setContextMenuPolicy(Qt::CustomContextMenu); |
||||
|
setAttribute(Qt::WA_AcceptTouchEvents, true); |
||||
|
setStyleSheet(QStringLiteral("QTreeView{ border: none; }")); |
||||
|
|
||||
|
connect(this, &QTreeView::expanded, this, &GameTree::OnItemExpanded); |
||||
|
connect(this, &QTreeView::collapsed, this, &GameTree::OnItemExpanded); |
||||
|
} |
||||
|
|
||||
|
void GameTree::SetModel(GameListModel* model) { |
||||
|
QTreeView::setModel(model); |
||||
|
LoadInterfaceLayout(); |
||||
|
UpdateColumnVisibility(model); |
||||
|
} |
||||
|
|
||||
|
void GameTree::OnItemExpanded(const QModelIndex& item) { |
||||
|
const auto type = item.data(GameListItem::TypeRole).value<GameListItemType>(); |
||||
|
const bool is_dir = type == GameListItemType::CustomDir || type == GameListItemType::SdmcDir || |
||||
|
type == GameListItemType::UserNandDir || |
||||
|
type == GameListItemType::SysNandDir; |
||||
|
const bool is_fave = type == GameListItemType::Favorites; |
||||
|
if (!is_dir && !is_fave) { |
||||
|
return; |
||||
|
} |
||||
|
const bool is_expanded = isExpanded(item); |
||||
|
if (is_fave) { |
||||
|
UISettings::values.favorites_expanded = is_expanded; |
||||
|
return; |
||||
|
} |
||||
|
const int item_dir_index = item.data(GameListDir::GameDirRole).toInt(); |
||||
|
UISettings::values.game_dirs[item_dir_index].expanded = is_expanded; |
||||
|
} |
||||
|
|
||||
|
void GameTree::SaveInterfaceLayout() { |
||||
|
UISettings::values.gamelist_header_state = header()->saveState(); |
||||
|
} |
||||
|
|
||||
|
void GameTree::LoadInterfaceLayout() { |
||||
|
auto* hdr = header(); |
||||
|
|
||||
|
if (hdr->restoreState(UISettings::values.gamelist_header_state)) |
||||
|
return; |
||||
|
|
||||
|
hdr->resizeSection(GameListModel::COLUMN_NAME, 840); |
||||
|
} |
||||
|
|
||||
|
void GameTree::UpdateColumnVisibility(GameListModel* model) { |
||||
|
Q_UNUSED(model) |
||||
|
setColumnHidden(GameListModel::COLUMN_ADD_ONS, !UISettings::values.show_add_ons); |
||||
|
setColumnHidden(GameListModel::COLUMN_COMPATIBILITY, !UISettings::values.show_compat); |
||||
|
setColumnHidden(GameListModel::COLUMN_FILE_TYPE, !UISettings::values.show_types); |
||||
|
setColumnHidden(GameListModel::COLUMN_SIZE, !UISettings::values.show_size); |
||||
|
setColumnHidden(GameListModel::COLUMN_PLAY_TIME, !UISettings::values.show_play_time); |
||||
|
} |
||||
|
|
||||
|
QString GameTree::GetLastFilterResultItem() const { |
||||
|
QString file_path; |
||||
|
|
||||
|
auto* model = qobject_cast<GameListModel*>(QTreeView::model()); |
||||
|
if (!model) |
||||
|
return {}; |
||||
|
|
||||
|
for (int i = 1; i < model->rowCount() - 1; ++i) { |
||||
|
const QStandardItem* folder = model->item(i, 0); |
||||
|
const QModelIndex folder_index = folder->index(); |
||||
|
const int children_count = folder->rowCount(); |
||||
|
|
||||
|
for (int j = 0; j < children_count; ++j) { |
||||
|
if (isRowHidden(j, folder_index)) { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const QStandardItem* child = folder->child(j, 0); |
||||
|
file_path = child->data(GameListItemPath::FullPathRole).toString(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return file_path; |
||||
|
} |
||||
|
|
||||
|
int GameTree::FilterClosedResultCount(GameListModel* model) { |
||||
|
int children_total = 0; |
||||
|
|
||||
|
auto hide_favorites_row = UISettings::values.favorited_ids.size() == 0; |
||||
|
setRowHidden(0, model->invisibleRootItem()->index(), hide_favorites_row); |
||||
|
|
||||
|
for (int i = 1; i < model->rowCount() - 1; ++i) { |
||||
|
auto* folder = model->item(i, 0); |
||||
|
const QModelIndex folder_index = folder->index(); |
||||
|
const int children_count = folder->rowCount(); |
||||
|
for (int j = 0; j < children_count; ++j) { |
||||
|
++children_total; |
||||
|
setRowHidden(j, folder_index, false); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return children_total; |
||||
|
} |
||||
|
|
||||
|
void GameTree::ApplyFilter(const QString& edit_filter_text, GameListModel* model) { |
||||
|
int children_total = 0; |
||||
|
int result_count = 0; |
||||
|
|
||||
|
if (edit_filter_text.isEmpty()) { |
||||
|
children_total = FilterClosedResultCount(model); |
||||
|
emit FilterResultReady(children_total, children_total); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
setRowHidden(0, model->invisibleRootItem()->index(), true); |
||||
|
|
||||
|
for (int i = 1; i < model->rowCount() - 1; ++i) { |
||||
|
auto* folder = model->item(i, 0); |
||||
|
const QModelIndex folder_index = folder->index(); |
||||
|
const int children_count = folder->rowCount(); |
||||
|
|
||||
|
for (int j = 0; j < children_count; ++j) { |
||||
|
++children_total; |
||||
|
|
||||
|
const QStandardItem* child = folder->child(j, 0); |
||||
|
|
||||
|
const auto program_id = child->data(GameListItemPath::ProgramIdRole).toULongLong(); |
||||
|
|
||||
|
const QString file_path = |
||||
|
child->data(GameListItemPath::FullPathRole).toString().toLower(); |
||||
|
const QString file_title = |
||||
|
child->data(GameListItemPath::TitleRole).toString().toLower(); |
||||
|
const QString file_program_id = |
||||
|
QStringLiteral("%1").arg(program_id, 16, 16, QLatin1Char{'0'}); |
||||
|
|
||||
|
const QString file_name = |
||||
|
file_path.mid(file_path.lastIndexOf(QLatin1Char{'/'}) + 1) + QLatin1Char{' '} + |
||||
|
file_title; |
||||
|
|
||||
|
auto ContainsAllWords = [](const QString& haystack, const QString& userinput) { |
||||
|
const QStringList userinput_split = |
||||
|
userinput.split(QLatin1Char{' '}, Qt::SkipEmptyParts); |
||||
|
return std::all_of(userinput_split.begin(), userinput_split.end(), |
||||
|
[&haystack](const QString& s) { return haystack.contains(s); }); |
||||
|
}; |
||||
|
|
||||
|
if (ContainsAllWords(file_name, edit_filter_text) || |
||||
|
(file_program_id.size() == 16 && file_program_id.contains(edit_filter_text))) { |
||||
|
setRowHidden(j, folder_index, false); |
||||
|
++result_count; |
||||
|
} else { |
||||
|
setRowHidden(j, folder_index, true); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
emit FilterResultReady(result_count, children_total); |
||||
|
} |
||||
@ -0,0 +1,35 @@ |
|||||
|
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project |
||||
|
// SPDX-License-Identifier: GPL-3.0-or-later |
||||
|
|
||||
|
#pragma once |
||||
|
|
||||
|
#include <QHeaderView> |
||||
|
#include <QString> |
||||
|
#include <QTreeView> |
||||
|
|
||||
|
class GameListModel; |
||||
|
|
||||
|
class GameTree : public QTreeView { |
||||
|
Q_OBJECT |
||||
|
|
||||
|
public: |
||||
|
explicit GameTree(QWidget* parent = nullptr); |
||||
|
|
||||
|
void SetModel(GameListModel* model); |
||||
|
|
||||
|
QString GetLastFilterResultItem() const; |
||||
|
int FilterClosedResultCount(GameListModel* model); |
||||
|
void ApplyFilter(const QString& edit_filter_text, GameListModel* model); |
||||
|
|
||||
|
void SaveInterfaceLayout(); |
||||
|
void LoadInterfaceLayout(); |
||||
|
|
||||
|
void UpdateColumnVisibility(GameListModel* model); |
||||
|
|
||||
|
signals: |
||||
|
void ItemExpandedChanged(const QModelIndex& item); |
||||
|
void FilterResultReady(int visible, int total); |
||||
|
|
||||
|
private slots: |
||||
|
void OnItemExpanded(const QModelIndex& item); |
||||
|
}; |
||||
@ -0,0 +1,124 @@ |
|||||
|
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
|
||||
|
#include "game/game_list.h"
|
||||
|
#include "game/search_field.h"
|
||||
|
|
||||
|
#include <QKeyEvent>
|
||||
|
#include <QToolButton>
|
||||
|
|
||||
|
// TODO: Remove GameList dependence?
|
||||
|
GameListSearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist_, QObject* parent) |
||||
|
: QObject(parent), gamelist{gamelist_} {} |
||||
|
|
||||
|
// EventFilter in order to process systemkeys while editing the searchfield
|
||||
|
bool GameListSearchField::KeyReleaseEater::eventFilter(QObject* obj, QEvent* event) { |
||||
|
// If it isn't a KeyRelease event then continue with standard event processing
|
||||
|
if (event->type() != QEvent::KeyRelease) |
||||
|
return QObject::eventFilter(obj, event); |
||||
|
|
||||
|
QKeyEvent* keyEvent = static_cast<QKeyEvent*>(event); |
||||
|
QString edit_filter_text = gamelist->search_field->edit_filter->text().toLower(); |
||||
|
|
||||
|
// If the searchfield's text hasn't changed special function keys get checked
|
||||
|
// If no function key changes the searchfield's text the filter doesn't need to get reloaded
|
||||
|
if (edit_filter_text == edit_filter_text_old) { |
||||
|
switch (keyEvent->key()) { |
||||
|
// Escape: Resets the searchfield
|
||||
|
case Qt::Key_Escape: { |
||||
|
if (edit_filter_text_old.isEmpty()) { |
||||
|
return QObject::eventFilter(obj, event); |
||||
|
} else { |
||||
|
gamelist->search_field->edit_filter->clear(); |
||||
|
edit_filter_text.clear(); |
||||
|
} |
||||
|
break; |
||||
|
} |
||||
|
// Return and Enter
|
||||
|
// If the enter key gets pressed first checks how many and which entry is visible
|
||||
|
// If there is only one result launch this game
|
||||
|
case Qt::Key_Return: |
||||
|
case Qt::Key_Enter: { |
||||
|
if (gamelist->search_field->visible == 1) { |
||||
|
const QString file_path = gamelist->GetLastFilterResultItem(); |
||||
|
|
||||
|
// To avoid loading error dialog loops while confirming them using enter
|
||||
|
// Also users usually want to run a different game after closing one
|
||||
|
gamelist->search_field->edit_filter->clear(); |
||||
|
edit_filter_text.clear(); |
||||
|
emit gamelist->GameChosen(file_path); |
||||
|
} else { |
||||
|
return QObject::eventFilter(obj, event); |
||||
|
} |
||||
|
break; |
||||
|
} |
||||
|
default: |
||||
|
return QObject::eventFilter(obj, event); |
||||
|
} |
||||
|
} |
||||
|
edit_filter_text_old = edit_filter_text; |
||||
|
return QObject::eventFilter(obj, event); |
||||
|
} |
||||
|
|
||||
|
void GameListSearchField::setFilterResult(int visible_, int total_) { |
||||
|
visible = visible_; |
||||
|
total = total_; |
||||
|
|
||||
|
label_filter_result->setText(tr("%1 of %n result(s)", "", total).arg(visible)); |
||||
|
} |
||||
|
|
||||
|
QString GameListSearchField::filterText() const { |
||||
|
return edit_filter->text(); |
||||
|
} |
||||
|
|
||||
|
void GameListSearchField::clear() { |
||||
|
edit_filter->clear(); |
||||
|
} |
||||
|
|
||||
|
void GameListSearchField::setFocus() { |
||||
|
if (edit_filter->isVisible()) { |
||||
|
edit_filter->setFocus(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
GameListSearchField::GameListSearchField(GameList* parent) : QWidget{parent} { |
||||
|
auto* const key_release_eater = new KeyReleaseEater(parent, this); |
||||
|
layout_filter = new QHBoxLayout; |
||||
|
layout_filter->setContentsMargins(8, 8, 8, 8); |
||||
|
label_filter = new QLabel; |
||||
|
edit_filter = new QLineEdit; |
||||
|
edit_filter->clear(); |
||||
|
edit_filter->installEventFilter(key_release_eater); |
||||
|
edit_filter->setClearButtonEnabled(true); |
||||
|
connect(edit_filter, &QLineEdit::textChanged, parent, &GameList::OnTextChanged); |
||||
|
label_filter_result = new QLabel; |
||||
|
button_filter_close = new QToolButton(this); |
||||
|
button_filter_close->setText(QStringLiteral("X")); |
||||
|
button_filter_close->setCursor(Qt::ArrowCursor); |
||||
|
button_filter_close->setStyleSheet( |
||||
|
QStringLiteral("QToolButton{ border: none; padding: 0px; color: " |
||||
|
"#000000; font-weight: bold; background: #F0F0F0; }" |
||||
|
"QToolButton:hover{ border: none; padding: 0px; color: " |
||||
|
"#EEEEEE; font-weight: bold; background: #E81123}")); |
||||
|
connect(button_filter_close, &QToolButton::clicked, parent, &GameList::OnFilterCloseClicked); |
||||
|
layout_filter->setSpacing(10); |
||||
|
layout_filter->addWidget(label_filter); |
||||
|
layout_filter->addWidget(edit_filter); |
||||
|
layout_filter->addWidget(label_filter_result); |
||||
|
layout_filter->addWidget(button_filter_close); |
||||
|
setLayout(layout_filter); |
||||
|
RetranslateUI(); |
||||
|
} |
||||
|
|
||||
|
void GameListSearchField::changeEvent(QEvent* event) { |
||||
|
if (event->type() == QEvent::LanguageChange) { |
||||
|
RetranslateUI(); |
||||
|
} |
||||
|
|
||||
|
QWidget::changeEvent(event); |
||||
|
} |
||||
|
|
||||
|
void GameListSearchField::RetranslateUI() { |
||||
|
label_filter->setText(tr("Filter:")); |
||||
|
edit_filter->setPlaceholderText(tr("Enter pattern to filter")); |
||||
|
} |
||||
@ -0,0 +1,52 @@ |
|||||
|
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project |
||||
|
// SPDX-License-Identifier: GPL-3.0-or-later |
||||
|
|
||||
|
#pragma once |
||||
|
|
||||
|
#include <QWidget> |
||||
|
|
||||
|
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; |
||||
|
}; |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue