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