diff --git a/src/qt_common/CMakeLists.txt b/src/qt_common/CMakeLists.txt index a8c4e51240..8858b819d6 100644 --- a/src/qt_common/CMakeLists.txt +++ b/src/qt_common/CMakeLists.txt @@ -31,6 +31,10 @@ add_library(qt_common STATIC abstract/frontend.h abstract/frontend.cpp abstract/progress.h abstract/progress.cpp + game_list/model.h game_list/model.cpp + game_list/worker.h game_list/worker.cpp + game_list/game_list_p.h + qt_string_lookup.h qt_compat.h diff --git a/src/yuzu/game/game_list_p.h b/src/qt_common/game_list/game_list_p.h similarity index 91% rename from src/yuzu/game/game_list_p.h rename to src/qt_common/game_list/game_list_p.h index f9d714fb8b..3994887755 100644 --- a/src/yuzu/game/game_list_p.h +++ b/src/qt_common/game_list/game_list_p.h @@ -17,14 +17,13 @@ #include #include #include -#include #include "common/common_types.h" #include "common/logging.h" #include "common/string_util.h" #include "frontend_common/play_time_manager.h" #include "qt_common/config/uisettings.h" -#include "yuzu/util/util.h" +#include "qt_common/qt_common.h" enum class GameListItemType { Game = QStandardItem::UserType + 1, @@ -204,7 +203,7 @@ public: setData(compatibility, CompatNumberRole); setText(tr(status.text)); setToolTip(tr(status.tooltip)); - setData(CreateCirclePixmapFromColor(status.color), Qt::DecorationRole); + setData(QtCommon::CreateCirclePixmapFromColor(status.color), Qt::DecorationRole); } int type() const override { @@ -237,7 +236,7 @@ public: // 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(QtCommon::ReadableByteSize(size_bytes), Qt::DisplayRole); GameListItem::setData(value, SizeRole); } else { GameListItem::setData(value, role); @@ -398,49 +397,3 @@ public: 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; -}; diff --git a/src/qt_common/game_list/model.cpp b/src/qt_common/game_list/model.cpp new file mode 100644 index 0000000000..2f8deaa0c5 --- /dev/null +++ b/src/qt_common/game_list/model.cpp @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include +#include + +#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 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& game_dirs) { + current_worker.reset(); + removeRows(0, rowCount()); + + current_worker = std::make_unique(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& 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(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 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()) { + case GameListItemType::SdmcDir: + child->setData( + QIcon::fromTheme(QStringLiteral("sd_card")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + case GameListItemType::UserNandDir: + case GameListItemType::SysNandDir: + child->setData( + QIcon::fromTheme(QStringLiteral("chip")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + case GameListItemType::CustomDir: { + const UISettings::GameDir& game_dir = + UISettings::values.game_dirs[child->data(GameListDir::GameDirRole).toInt()]; + const QString icon_name = QFileInfo::exists(QString::fromStdString(game_dir.path)) + ? QStringLiteral("folder") + : QStringLiteral("bad_folder"); + child->setData( + QIcon::fromTheme(icon_name).pixmap(icon_size).scaled( + icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + } + case GameListItemType::AddDir: + child->setData( + QIcon::fromTheme(QStringLiteral("list-add")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + case GameListItemType::Favorites: + child->setData( + QIcon::fromTheme(QStringLiteral("star")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; + default: + break; + } + } +} + +void GameListModel::RetranslateUI() { + setHeaderData(COLUMN_NAME, Qt::Horizontal, tr("Name")); + setHeaderData(COLUMN_COMPATIBILITY, Qt::Horizontal, tr("Compatibility")); + setHeaderData(COLUMN_ADD_ONS, Qt::Horizontal, tr("Add-ons")); + setHeaderData(COLUMN_FILE_TYPE, Qt::Horizontal, tr("File type")); + setHeaderData(COLUMN_SIZE, Qt::Horizontal, tr("Size")); + setHeaderData(COLUMN_PLAY_TIME, Qt::Horizontal, tr("Play time")); +} + +QFileSystemWatcher* GameListModel::GetWatcher() const { + return watcher; +} + +const CompatibilityList& GameListModel::GetCompatibilityList() const { + return compatibility_list; +} + +void GameListModel::SetFlat(bool flat) { + m_flat = flat; +} diff --git a/src/qt_common/game_list/model.h b/src/qt_common/game_list/model.h new file mode 100644 index 0000000000..d0a116beb6 --- /dev/null +++ b/src/qt_common/game_list/model.h @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#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 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& entry_items, GameListDir* parent); + void DonePopulating(const QStringList& watch_list); + + void PopulateAsync(QVector& 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 vfs; + FileSys::ManualContentProvider* provider; + CompatibilityList compatibility_list; + const PlayTime::PlayTimeManager& play_time_manager; + Core::System& system; + + std::unique_ptr current_worker; + QFileSystemWatcher* watcher = nullptr; + QFileSystemWatcher* external_watcher = nullptr; +}; diff --git a/src/yuzu/game/game_list_worker.cpp b/src/qt_common/game_list/worker.cpp similarity index 96% rename from src/yuzu/game/game_list_worker.cpp rename to src/qt_common/game_list/worker.cpp index df3560a813..f1499265c6 100644 --- a/src/yuzu/game/game_list_worker.cpp +++ b/src/qt_common/game_list/worker.cpp @@ -33,9 +33,10 @@ #include "qt_common/qt_common.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/game_list/game_list_p.h" + +#include "qt_common/game_list/worker.h" +#include "qt_common/game_list/model.h" namespace { @@ -250,9 +251,9 @@ GameListWorker::~GameListWorker() { processing_completed.Wait(); } -void GameListWorker::ProcessEvents(GameList* game_list) { +void GameListWorker::ProcessEvents(GameListModel* model) { while (true) { - std::function func; + std::function func; { // Lock queue to protect concurrent modification. std::scoped_lock lk(lock); @@ -268,7 +269,7 @@ void GameListWorker::ProcessEvents(GameList* game_list) { } // Run the function. - func(game_list); + func(model); } } @@ -335,7 +336,7 @@ void GameListWorker::AddTitlesToGameList(GameListDir* parent_dir) { 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); }); + RecordEvent([=](GameListModel* model) { model->AddEntry(entry, parent_dir); }); } } @@ -417,7 +418,7 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa id, compatibility_list, play_time_manager, patch); RecordEvent( - [=](GameList* game_list) { game_list->AddEntry(entry, parent_dir); }); + [=](GameListModel* model) { model->AddEntry(entry, parent_dir); }); } } else { std::vector icon; @@ -434,7 +435,7 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa program_id, compatibility_list, play_time_manager, patch); RecordEvent( - [=](GameList* game_list) { game_list->AddEntry(entry, parent_dir); }); + [=](GameListModel* model) { model->AddEntry(entry, parent_dir); }); } } } else if (is_dir) { @@ -457,7 +458,7 @@ void GameListWorker::run() { provider->ClearAllEntries(); const auto DirEntryReady = [&](GameListDir* game_list_dir) { - RecordEvent([=](GameList* game_list) { game_list->AddDirEntry(game_list_dir); }); + RecordEvent([=](GameListModel* model) { model->AddDirEntry(game_list_dir); }); }; for (UISettings::GameDir& game_dir : game_dirs) { @@ -491,6 +492,6 @@ void GameListWorker::run() { } } - RecordEvent([this](GameList* game_list) { game_list->DonePopulating(watch_list); }); + RecordEvent([this](GameListModel* model) { model->DonePopulating(watch_list); }); processing_completed.Set(); } diff --git a/src/yuzu/game/game_list_worker.h b/src/qt_common/game_list/worker.h similarity index 87% rename from src/yuzu/game/game_list_worker.h rename to src/qt_common/game_list/worker.h index bf67585fe6..c11741c5f5 100644 --- a/src/yuzu/game/game_list_worker.h +++ b/src/qt_common/game_list/worker.h @@ -26,8 +26,8 @@ namespace Core { class System; } -class GameList; class GameListDir; +class GameListModel; class QStandardItem; namespace FileSys { @@ -58,11 +58,11 @@ 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. + * AddDirEntry is called on the model for every discovered directory. + * AddEntry is called on the model for every discovered program. + * DonePopulating is called on the model when processing completes. */ - void ProcessEvents(GameList* game_list); + void ProcessEvents(GameListModel* model); signals: void DataAvailable(); @@ -92,7 +92,7 @@ private: std::mutex lock; std::condition_variable cv; - std::deque> queued_events; + std::deque> queued_events; std::atomic_bool stop_requested = false; Common::Event processing_completed; diff --git a/src/qt_common/qt_common.cpp b/src/qt_common/qt_common.cpp index 98de0a6317..b9b8caf6e8 100644 --- a/src/qt_common/qt_common.cpp +++ b/src/qt_common/qt_common.cpp @@ -5,6 +5,7 @@ #include "common/memory_detect.h" #include "core/hle/service/filesystem/filesystem.h" +#include "frontend_common/data_manager.h" #include "hid_core/hid_core.h" #include "network/network.h" #include "qt_common.h" @@ -27,6 +28,7 @@ #include #include +#include #if !defined(WIN32) && !defined(__APPLE__) #include @@ -307,4 +309,19 @@ void SetupHID() { system->HIDCore().ReloadInputDevices(); } +QString ReadableByteSize(qulonglong size) { + return QString::fromStdString(FrontendCommon::DataManager::ReadableBytesSize(size)); +} + +QPixmap CreateCirclePixmapFromColor(const QColor& color) { + QPixmap circle_pixmap(16, 16); + circle_pixmap.fill(Qt::transparent); + QPainter painter(&circle_pixmap); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(color); + painter.setBrush(color); + painter.drawEllipse({circle_pixmap.width() / 2.0, circle_pixmap.height() / 2.0}, 7.0, 7.0); + return circle_pixmap; +} + } // namespace QtCommon diff --git a/src/qt_common/qt_common.h b/src/qt_common/qt_common.h index dd860ef5f7..2109774506 100644 --- a/src/qt_common/qt_common.h +++ b/src/qt_common/qt_common.h @@ -46,6 +46,20 @@ void SetupHID(); const QString tr(const char* str); const QString tr(const std::string& str); +// TODO: Find a better place for these + +/// Convert a size in bytes into a readable format (KiB, MiB, etc.) +[[nodiscard]] QString ReadableByteSize(qulonglong size); + +/** + * Creates a circle pixmap from a specified color + * @param color The color the pixmap shall have + * @return QPixmap circle pixmap + */ +[[nodiscard]] QPixmap CreateCirclePixmapFromColor(const QColor& color); + + + std::filesystem::path GetEdenCommand(); } // namespace QtCommon #endif diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt index 0bddcca3e8..a570f5dd41 100644 --- a/src/yuzu/CMakeLists.txt +++ b/src/yuzu/CMakeLists.txt @@ -156,13 +156,12 @@ add_executable(yuzu debugger/controller.cpp debugger/controller.h - game/game_list.cpp - game/game_list.h - game/game_list_p.h - game/game_list_worker.cpp - game/game_list_worker.h - game/game_card.h - game/game_card.cpp + game/game_list.cpp game/game_list.h + game/game_grid.cpp game/game_grid.h + + game/game_tree.h game/game_tree.cpp + game/game_card.h game/game_card.cpp + game/search_field.h game/search_field.cpp hotkeys.cpp hotkeys.h @@ -219,18 +218,16 @@ add_executable(yuzu util/util.h compatdb.cpp compatdb.h - user_data_migration.cpp - user_data_migration.h + + user_data_migration.h user_data_migration.cpp yuzu.qrc yuzu.rc migration_dialog.h migration_dialog.cpp - migration_worker.h - migration_worker.cpp + migration_worker.h migration_worker.cpp + libqt_common.h libqt_common.cpp - deps_dialog.cpp - deps_dialog.h - deps_dialog.ui + deps_dialog.cpp deps_dialog.h deps_dialog.ui data_dialog.h data_dialog.cpp data_dialog.ui data_widget.ui @@ -242,7 +239,6 @@ add_executable(yuzu configuration/addon/mod_select_dialog.h configuration/addon/mod_select_dialog.cpp configuration/addon/mod_select_dialog.ui render/performance_overlay.h render/performance_overlay.cpp render/performance_overlay.ui - libqt_common.h libqt_common.cpp updater/update_dialog.h updater/update_dialog.cpp updater/update_dialog.ui) set_target_properties(yuzu PROPERTIES OUTPUT_NAME "eden") diff --git a/src/yuzu/configuration/configure_per_game.cpp b/src/yuzu/configuration/configure_per_game.cpp index f84448dd29..6f530eb0ab 100644 --- a/src/yuzu/configuration/configure_per_game.cpp +++ b/src/yuzu/configuration/configure_per_game.cpp @@ -30,6 +30,7 @@ #include "core/loader/loader.h" #include "frontend_common/config.h" #include "qt_common/config/uisettings.h" +#include "qt_common/qt_common.h" #include "qt_common/util/vk.h" #include "ui_configure_per_game.h" #include "yuzu/configuration/configuration_shared.h" @@ -205,6 +206,6 @@ void ConfigurePerGame::LoadConfiguration() { ui->display_format->setText( QString::fromStdString(Loader::GetFileTypeString(loader->GetFileType()))); - const auto valueText = ReadableByteSize(file->GetSize()); + const auto valueText = QtCommon::ReadableByteSize(file->GetSize()); ui->display_size->setText(valueText); } diff --git a/src/yuzu/game/game_card.cpp b/src/yuzu/game/game_card.cpp index 03c71449f8..e1cd92652e 100644 --- a/src/yuzu/game/game_card.cpp +++ b/src/yuzu/game/game_card.cpp @@ -18,34 +18,27 @@ void GameCard::paint(QPainter* painter, const QStyleOptionViewItem& option, painter->save(); painter->setRenderHint(QPainter::Antialiasing); - // Padding, dimensions, alignment... + constexpr int cardMargin = 8; + constexpr int cardCornerRadius = 10; + const int column = index.row() % m_columns; const int cell_width = option.rect.width(); - const int fixed_card_width = cell_width - m_padding; - const int margins = 8; + const int card_width = cell_width - m_padding; - // The gist of it is that this anchors the left and right sides to the edges, - // while maintaining an even gap between each card. - // I just smashed random keys into my keyboard until something worked. - // Don't even bother trying to figure out what the hell this is doing. - const auto total_row_width = m_columns * cell_width; - const auto total_gap_space = total_row_width - (margins * 2) - (m_columns * fixed_card_width); - const auto gap = (m_columns > 1) ? (total_gap_space / (m_columns - 1)) : 0; + const int row_width = m_columns * cell_width; + const int total_gap = row_width - cardMargin * 2 - m_columns * card_width; + const int gap = (m_columns > 1) ? (total_gap / (m_columns - 1)) : 0; - const auto relative_x = margins + (column * (fixed_card_width + gap)); - const auto x_pos = option.rect.left() - (column * cell_width) + static_cast(relative_x); + const int card_left = option.rect.left() - column * cell_width + cardMargin + column * (card_width + gap) + 4; + const QRect cardRect(card_left, option.rect.top() + 4, card_width - 8, + option.rect.height() - cardMargin); - // also, add some additional padding here to prevent card overlap - QRect cardRect(x_pos + 4, option.rect.top() + 4, fixed_card_width - 8, - option.rect.height() - margins); - - // 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 + // highlight blue on select if (option.state & QStyle::State_Selected) { backgroundColor = palette.highlight().color(); borderColor = palette.highlight().color().lighter(150); @@ -54,63 +47,45 @@ void GameCard::paint(QPainter* painter, const QStyleOptionViewItem& option, backgroundColor = backgroundColor.lighter(120); } - // bg painter->setBrush(backgroundColor); painter->setPen(QPen(borderColor, 1)); - painter->drawRoundedRect(cardRect, 10, 10); + painter->drawRoundedRect(cardRect, cardCornerRadius, cardCornerRadius); - // icon - int _iconsize = UISettings::values.game_icon_size.GetValue(); - QSize iconSize(_iconsize, _iconsize); - QPixmap iconPixmap = index.data(Qt::DecorationRole).value(); + const u32 icon_size = UISettings::values.game_icon_size.GetValue(); + QPixmap icon_pixmap = index.data(Qt::DecorationRole).value(); QRect iconRect; - if (!iconPixmap.isNull()) { - QSize scaledSize = iconPixmap.size(); - scaledSize.scale(iconSize, Qt::KeepAspectRatio); + if (!icon_pixmap.isNull()) { + QSize scaled = icon_pixmap.size(); + scaled.scale(icon_size, icon_size, Qt::KeepAspectRatio); - int x = cardRect.left() + (cardRect.width() - scaledSize.width()) / 2; - int y = cardRect.top() + margins; - - iconRect = QRect(x, y, scaledSize.width(), scaledSize.height()); + iconRect = {cardRect.left() + (cardRect.width() - scaled.width()) / 2, + cardRect.top() + cardMargin, scaled.width(), scaled.height()}; painter->setRenderHint(QPainter::SmoothPixmapTransform, true); - // Put this in a separate thing on the painter stack to prevent clipping the text. painter->save(); - - // round image edges - QPainterPath path; - path.addRoundedRect(iconRect, 10, 10); - painter->setClipPath(path); - - painter->drawPixmap(iconRect, iconPixmap); - + QPainterPath clip_path; + clip_path.addRoundedRect(iconRect, cardCornerRadius, cardCornerRadius); + painter->setClipPath(clip_path); + painter->drawPixmap(iconRect, icon_pixmap); painter->restore(); } else { - // if there is no icon just draw a blank rect - iconRect = QRect(cardRect.left() + margins, cardRect.top() + margins, _iconsize, _iconsize); + iconRect = {cardRect.left() + cardMargin, cardRect.top() + cardMargin, + static_cast(icon_size), static_cast(icon_size)}; } if (UISettings::values.show_game_name.GetValue()) { - // padding + text QRect textRect = cardRect; - textRect.setTop(iconRect.bottom() + margins); - textRect.adjust(margins, 0, -margins, -margins); + textRect.setTop(iconRect.bottom() + cardMargin); + textRect.adjust(cardMargin, 0, -cardMargin, -cardMargin); - // We are already crammed on space, ignore the row 2 - QString title = index.data(Qt::DisplayRole).toString(); - title = title.split(QLatin1Char('\n')).first(); + QString title = index.data(Qt::DisplayRole).toString().split(QLatin1Char('\n')).first(); - // now draw text painter->setPen(textColor); QFont font = option.font; font.setBold(true); - - // TODO(crueter): fix this abysmal scaling - font.setPixelSize(1.5 + std::max(10.0, std::sqrt(_iconsize))); - - // TODO(crueter): elide mode + font.setPixelSize(std::max(11.0, std::sqrt(static_cast(icon_size)))); painter->setFont(font); painter->drawText(textRect, Qt::AlignHCenter | Qt::AlignTop | Qt::TextWordWrap, title); diff --git a/src/yuzu/game/game_grid.cpp b/src/yuzu/game/game_grid.cpp new file mode 100644 index 0000000000..88e4a205a8 --- /dev/null +++ b/src/yuzu/game/game_grid.cpp @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +#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); + } +} diff --git a/src/yuzu/game/game_grid.h b/src/yuzu/game/game_grid.h new file mode 100644 index 0000000000..b670bfc3cb --- /dev/null +++ b/src/yuzu/game/game_grid.h @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#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; +}; diff --git a/src/yuzu/game/game_list.cpp b/src/yuzu/game/game_list.cpp index b5967bacca..63267ddec6 100644 --- a/src/yuzu/game/game_list.cpp +++ b/src/yuzu/game/game_list.cpp @@ -1,6 +1,9 @@ // 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 + #include #include #include @@ -15,431 +18,60 @@ #include #include #include -#include #include #include -#include -#include + #include "common/common_types.h" #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 "game/game_card.h" #include "qt_common/config/uisettings.h" #include "qt_common/qt_common.h" #include "qt_common/util/game.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/game_list/game_list_p.h" +#include "yuzu/game/game_grid.h" +#include "yuzu/game/game_tree.h" +#include "qt_common/game_list/model.h" #include "yuzu/main_window.h" #include "yuzu/util/controller_navigation.h" - -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(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(); -} - -QString GameList::GetLastFilterResultItem() const { - QString file_path; - - for (int i = 1; i < item_model->rowCount() - 1; ++i) { - const QStandardItem* folder = item_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 (tree_view->isRowHidden(j, folder_index)) { - continue; - } - - const QStandardItem* child = folder->child(j, 0); - file_path = child->data(GameListItemPath::FullPathRole).toString(); - } - } - - return file_path; -} - -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(); -} - -/** - * Checks if all words separated by spaces are contained in another string - * This offers a word order insensitive search function - * - * @param haystack String that gets checked if it contains all words of the userinput string - * @param userinput String containing all words getting checked - * @return true if the haystack contains all words of userinput - */ -static bool 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); }); -} - -// Syncs the expanded state of Game Directories with settings to persist across sessions -void GameList::OnItemExpanded(const QModelIndex& item) { - const auto type = item.data(GameListItem::TypeRole).value(); - 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 = tree_view->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; -} - -// Event in order to filter the gamelist after editing the searchfield -void GameList::OnTextChanged(const QString& new_text) { - QString edit_filter_text = new_text.toLower(); - QStandardItem* folder; - int children_total = 0; - int result_count = 0; - - auto hide = [this](int row, bool hidden, QModelIndex index = QModelIndex()) { - if (m_isTreeMode) { - tree_view->setRowHidden(row, index, hidden); - } else { - list_view->setRowHidden(row, hidden); - } - }; - - // If the searchfield is empty every item is visible - // Otherwise the filter gets applied - - // TODO(crueter) dedupe - if (!m_isTreeMode) { - int row_count = item_model->rowCount(); - - for (int i = 0; i < row_count; ++i) { - QStandardItem* item = item_model->item(i, 0); - if (!item) - continue; - - children_total++; - - 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)) { - hide(i, false); - result_count++; - } else { - hide(i, true); - } - } - search_field->setFilterResult(result_count, children_total); - } else if (edit_filter_text.isEmpty()) { - hide(0, UISettings::values.favorited_ids.size() == 0, - item_model->invisibleRootItem()->index()); - for (int i = 1; i < item_model->rowCount() - 1; ++i) { - folder = item_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; - hide(j, false, folder_index); - } - } - search_field->setFilterResult(children_total, children_total); - } else { - hide(0, true, item_model->invisibleRootItem()->index()); - for (int i = 1; i < item_model->rowCount() - 1; ++i) { - folder = item_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'}); - - // Only items which filename in combination with its title contains all words - // that are in the searchfield will be visible in the gamelist - // The search is case insensitive because of toLower() - // I decided not to use Qt::CaseInsensitive in containsAllWords to prevent - // multiple conversions of edit_filter_text for each game in the gamelist - const QString file_name = - file_path.mid(file_path.lastIndexOf(QLatin1Char{'/'}) + 1) + QLatin1Char{' '} + - file_title; - if (ContainsAllWords(file_name, edit_filter_text) || - (file_program_id.size() == 16 && file_program_id.contains(edit_filter_text))) { - hide(j, false, folder_index); - ++result_count; - } else { - hide(j, true, folder_index); - } - } - } - search_field->setFilterResult(result_count, children_total); - } -} - -void GameList::OnUpdateThemedIcons() { - for (int i = 0; i < item_model->invisibleRootItem()->rowCount(); i++) { - QStandardItem* child = item_model->invisibleRootItem()->child(i); - - const int icon_size = UISettings::values.folder_icon_size.GetValue(); - - switch (child->data(GameListItem::TypeRole).value()) { - 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: - child->setData( - QIcon::fromTheme(QStringLiteral("chip")) - .pixmap(icon_size) - .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), - Qt::DecorationRole); - break; - 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 GameList::OnFilterCloseClicked() { - main_window->filterBarSetChecked(false); -} +#include "yuzu/game/search_field.h" GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvider* provider_, PlayTime::PlayTimeManager& play_time_manager_, Core::System& system_, MainWindow* parent) : QWidget{parent}, vfs{std::move(vfs_)}, provider{provider_}, play_time_manager{play_time_manager_}, system{system_} { - watcher = new QFileSystemWatcher(this); - connect(watcher, &QFileSystemWatcher::directoryChanged, this, &GameList::RefreshGameDirectory); - - external_watcher = new QFileSystemWatcher(this); - ResetExternalWatcher(); - connect(external_watcher, &QFileSystemWatcher::directoryChanged, this, - &GameList::RefreshExternalContent); this->main_window = parent; layout = new QVBoxLayout; - tree_view = new QTreeView(this); - list_view = new QListView(this); - m_gameCard = new GameCard(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + setLayout(layout); - list_view->setItemDelegate(m_gameCard); + item_model = new GameListModel(vfs, provider, play_time_manager, system, this); + + SetupViews(); + + search_field = new GameListSearchField(this); + layout->addWidget(search_field); controller_navigation = new ControllerNavigation(system.HIDCore(), this); - search_field = new GameListSearchField(this); - item_model = new QStandardItemModel(tree_view); - tree_view->setModel(item_model); - list_view->setModel(item_model); SetupScrollAnimation(); - // tree - tree_view->setAlternatingRowColors(true); - tree_view->setSelectionMode(QHeaderView::SingleSelection); - tree_view->setSelectionBehavior(QHeaderView::SelectRows); - tree_view->setVerticalScrollMode(QHeaderView::ScrollPerPixel); - tree_view->setHorizontalScrollMode(QHeaderView::ScrollPerPixel); - tree_view->setSortingEnabled(true); - tree_view->setEditTriggers(QHeaderView::NoEditTriggers); - tree_view->setContextMenuPolicy(Qt::CustomContextMenu); - tree_view->setAttribute(Qt::WA_AcceptTouchEvents, true); - tree_view->setStyleSheet(QStringLiteral("QTreeView{ border: none; }")); - - // list view setup - list_view->setViewMode(QListView::ListMode); - list_view->setResizeMode(QListView::Fixed); - list_view->setUniformItemSizes(true); - list_view->setSelectionMode(QAbstractItemView::SingleSelection); - list_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); - list_view->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel); - - // Forcefully disable scroll bar, prevents thing where game list items - // will start clamping prematurely. - list_view->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - list_view->setEditTriggers(QAbstractItemView::NoEditTriggers); - list_view->setContextMenuPolicy(Qt::CustomContextMenu); - list_view->setGridSize(QSize(140, 160)); - m_gameCard->setSize(list_view->gridSize(), 0, 4); - - list_view->setSpacing(10); - list_view->setWordWrap(true); - list_view->setTextElideMode(Qt::ElideRight); - list_view->setFlow(QListView::LeftToRight); - list_view->setWrapping(true); - - item_model->insertColumns(0, COLUMN_COUNT); - RetranslateUI(); - - tree_view->setColumnHidden(COLUMN_ADD_ONS, !UISettings::values.show_add_ons); - tree_view->setColumnHidden(COLUMN_COMPATIBILITY, !UISettings::values.show_compat); - tree_view->setColumnHidden(COLUMN_PLAY_TIME, !UISettings::values.show_play_time); - item_model->setSortRole(GameListItemPath::SortRole); - connect(main_window, &MainWindow::UpdateThemedIcons, this, &GameList::OnUpdateThemedIcons); connect(tree_view, &QTreeView::activated, this, &GameList::ValidateEntry); connect(tree_view, &QTreeView::customContextMenuRequested, this, &GameList::PopupContextMenu); - connect(list_view, &QListView::activated, this, &GameList::ValidateEntry); - connect(list_view, &QListView::customContextMenuRequested, this, &GameList::PopupContextMenu); + connect(grid_view, &QListView::activated, this, &GameList::ValidateEntry); + connect(grid_view, &QListView::customContextMenuRequested, this, &GameList::PopupContextMenu); - connect(tree_view, &QTreeView::expanded, this, &GameList::OnItemExpanded); - connect(tree_view, &QTreeView::collapsed, this, &GameList::OnItemExpanded); connect(controller_navigation, &ControllerNavigation::TriggerKeyboardEvent, this, [this](Qt::Key key) { - // Avoid pressing buttons while playing if (system.IsPoweredOn()) { return; } @@ -447,31 +79,90 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid return; } QKeyEvent* event = new QKeyEvent(QEvent::KeyPress, key, Qt::NoModifier); - QCoreApplication::postEvent(m_currentView, event); }); - // We must register all custom types with the Qt Automoc system so that we are able to use - // it with signals/slots. In this case, QList falls under the umbrella of custom types. + connect(item_model, &GameListModel::PopulatingCompleted, this, + &GameList::OnPopulatingCompleted); + + connect(item_model, &GameListModel::ShowList, this, &GameList::ShowList); + connect(item_model, &GameListModel::SaveConfig, this, &GameList::SaveConfig); + + connect(tree_view, &GameTree::FilterResultReady, search_field, + [this](int visible, int total) { search_field->setFilterResult(visible, total); }); + qRegisterMetaType>("QList"); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - - layout->addWidget(tree_view); - layout->addWidget(list_view); - layout->addWidget(search_field); - setLayout(layout); - ResetViewMode(); } -void GameList::UnloadController() { - controller_navigation->UnloadController(); +GameList::~GameList() { + UnloadController(); } -bool GameList::IsTreeMode() { - return m_isTreeMode; +void GameList::SetupViews() { + tree_view = new GameTree(this); + grid_view = new GameGrid(this); + + tree_view->SetModel(item_model); + grid_view->SetModel(item_model); + + layout->addWidget(tree_view); + layout->addWidget(grid_view); +} + +QString GameList::GetLastFilterResultItem() const { + return tree_view->GetLastFilterResultItem(); +} + +void GameList::ClearFilter() { + search_field->clear(); +} + +void GameList::SetFilterFocus() { + if (item_model->rowCount() > 0) { + search_field->setFocus(); + } +} + +void GameList::SetFilterVisible(bool visibility) { + search_field->setVisible(visibility); +} + +bool GameList::IsEmpty() const { + return item_model->IsEmpty(); +} + +void GameList::LoadCompatibilityList() { + item_model->LoadCompatibilityList(); +} + +void GameList::PopulateAsync(QVector& game_dirs) { + m_currentView->setEnabled(false); + + tree_view->UpdateColumnVisibility(item_model); + + if (!m_isTreeMode) { + grid_view->UpdateIconSize(); + } + + item_model->PopulateAsync(game_dirs); +} + +void GameList::SaveInterfaceLayout() { + tree_view->SaveInterfaceLayout(); +} + +void GameList::LoadInterfaceLayout() { + tree_view->LoadInterfaceLayout(); +} + +QStandardItemModel* GameList::GetModel() const { + return item_model; +} + +void GameList::UnloadController() { + controller_navigation->UnloadController(); } void GameList::ResetViewMode() { @@ -482,15 +173,13 @@ void GameList::ResetViewMode() { case Settings::GameListMode::TreeView: m_currentView = tree_view; newTreeMode = true; - tree_view->setVisible(true); - list_view->setVisible(false); + grid_view->setVisible(false); break; case Settings::GameListMode::GridView: - m_currentView = list_view; + m_currentView = grid_view; newTreeMode = false; - - list_view->setVisible(true); + grid_view->setVisible(true); tree_view->setVisible(false); break; default: @@ -500,11 +189,9 @@ void GameList::ResetViewMode() { auto view = m_currentView->viewport(); view->installEventFilter(this); - // touch gestures view->grabGesture(Qt::SwipeGesture); view->grabGesture(Qt::PanGesture); - // TODO: touch? QScroller::grabGesture(view, QScroller::LeftMouseButtonGesture); auto scroller = QScroller::scroller(view); @@ -517,48 +204,120 @@ void GameList::ResetViewMode() { if (m_isTreeMode != newTreeMode) { m_isTreeMode = newTreeMode; - - RefreshGameDirectory(); + item_model->SetFlat(!m_isTreeMode); + if (!UISettings::values.game_dirs.empty()) { + item_model->PopulateAsync(UISettings::values.game_dirs); + } } } -GameList::~GameList() { - UnloadController(); +void GameList::OnTextChanged(const QString& new_text) { + const QString edit_filter_text = new_text.toLower(); + + if (m_isTreeMode) { + tree_view->ApplyFilter(edit_filter_text, item_model); + } else { + grid_view->ApplyFilter(edit_filter_text, item_model); + } } -void GameList::SetFilterFocus() { - if (tree_view->model()->rowCount() > 0) { +void GameList::OnFilterCloseClicked() { + main_window->filterBarSetChecked(false); +} + +void GameList::OnUpdateThemedIcons() { + item_model->OnUpdateThemedIcons(); +} + +void GameList::OnPopulatingCompleted(const QStringList& watch_list) { + emit ShowList(!item_model->IsEmpty()); + + // favorites row + if (m_isTreeMode) { + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), + UISettings::values.favorited_ids.size() == 0); + tree_view->setExpanded(item_model->invisibleRootItem()->child(0)->index(), + UISettings::values.favorites_expanded.GetValue()); + } + + // restore collapsed/expanded flags + if (m_isTreeMode) { + const auto* root = item_model->invisibleRootItem(); + for (int i = 1; i < root->rowCount() - 1; ++i) { + const auto* dir_item = root->child(i); + const auto type = dir_item->data(GameListItem::TypeRole).value(); + if (type == GameListItemType::CustomDir || type == GameListItemType::SdmcDir || + type == GameListItemType::UserNandDir || type == GameListItemType::SysNandDir) { + const int dir_index = dir_item->data(GameListDir::GameDirRole).toInt(); + if (dir_index >= 0 && dir_index < UISettings::values.game_dirs.size()) { + tree_view->setExpanded(dir_item->index(), + UISettings::values.game_dirs[dir_index].expanded); + } + } + } + } + + // Clear out the old directories to watch for changes and add the new ones + auto* watcher = item_model->GetWatcher(); + auto watch_dirs = watcher->directories(); + if (!watch_dirs.isEmpty()) { + watcher->removePaths(watch_dirs); + } + + constexpr int LIMIT_WATCH_DIRECTORIES = 5000; + constexpr int SLICE_SIZE = 25; + int len = (std::min)(static_cast(watch_list.size()), LIMIT_WATCH_DIRECTORIES); + +#ifdef __APPLE__ + const bool old_signals_blocked = watcher->blockSignals(true); +#endif + + for (int i = 0; i < len; i += SLICE_SIZE) { + auto chunk = watch_list.mid(i, SLICE_SIZE); + if (!chunk.isEmpty()) { + watcher->addPaths(chunk); + } + QCoreApplication::processEvents(); + } + +#ifdef __APPLE__ + watcher->blockSignals(old_signals_blocked); +#endif + + m_currentView->setEnabled(true); + + int children_total = 0; + for (int i = 1; i < item_model->rowCount() - 1; ++i) { + children_total += item_model->item(i, 0)->rowCount(); + } + search_field->setFilterResult(children_total, children_total); + if (children_total > 0) { search_field->setFocus(); } + + item_model->sort(tree_view->header()->sortIndicatorSection(), + tree_view->header()->sortIndicatorOrder()); + + emit PopulatingCompleted(); } -void GameList::SetFilterVisible(bool visibility) { - search_field->setVisible(visibility); -} +void GameList::RefreshGameDirectory() { + item_model->ResetExternalWatcher(); -void GameList::ClearFilter() { - search_field->clear(); -} - -void GameList::WorkerEvent() { - current_worker->ProcessEvents(this); -} - -void GameList::AddDirEntry(GameListDir* entry_items) { - if (m_isTreeMode) { - item_model->invisibleRootItem()->appendRow(entry_items); - tree_view->setExpanded( - entry_items->index(), - UISettings::values.game_dirs[entry_items->data(GameListDir::GameDirRole).toInt()] - .expanded); + if (!UISettings::values.game_dirs.empty()) { + LOG_INFO(Frontend, "Change detected in the games directory. Reloading game list."); + QtCommon::system->GetFileSystemController().CreateFactories(*QtCommon::vfs); + PopulateAsync(UISettings::values.game_dirs); } } -void GameList::AddEntry(const QList& entry_items, GameListDir* parent) { - if (!m_isTreeMode) - item_model->invisibleRootItem()->appendRow(entry_items); - else - parent->appendRow(entry_items); +void GameList::RefreshExternalContent() { + if (!UISettings::values.game_dirs.empty()) { + 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 GameList::ValidateEntry(const QModelIndex& item) { @@ -584,7 +343,6 @@ void GameList::ValidateEntry(const QModelIndex& item) { const auto title_id = selected.data(GameListItemPath::ProgramIdRole).toULongLong(); - // Users usually want to run a different game after closing one search_field->clear(); emit GameChosen(file_path, title_id); break; @@ -597,82 +355,19 @@ void GameList::ValidateEntry(const QModelIndex& item) { } } -bool GameList::IsEmpty() const { - for (int i = 0; i < item_model->rowCount(); i++) { - const QStandardItem* child = item_model->invisibleRootItem()->child(i); - const auto type = static_cast(child->type()); +void GameList::ToggleFavorite(u64 program_id) { + item_model->ToggleFavorite(program_id); - if (!child->hasChildren() && - (type == GameListItemType::SdmcDir || type == GameListItemType::UserNandDir || - type == GameListItemType::SysNandDir)) { - item_model->invisibleRootItem()->removeRow(child->row()); - i--; - } - } - - return !item_model->invisibleRootItem()->hasChildren(); -} - -void GameList::DonePopulating(const QStringList& watch_list) { - emit ShowList(!IsEmpty()); - - // Add favorites row - if (m_isTreeMode) { - item_model->invisibleRootItem()->appendRow(new GameListAddDir()); - - item_model->invisibleRootItem()->insertRow(0, new GameListFavorites()); + if (UISettings::values.favorited_ids.contains(program_id)) { tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), - UISettings::values.favorited_ids.size() == 0); - tree_view->setExpanded(item_model->invisibleRootItem()->child(0)->index(), - UISettings::values.favorites_expanded.GetValue()); - for (const auto id : std::as_const(UISettings::values.favorited_ids)) { - AddFavorite(id); + !search_field->filterText().isEmpty()); + item_model->sort(tree_view->header()->sortIndicatorSection(), + tree_view->header()->sortIndicatorOrder()); + } else { + if (UISettings::values.favorited_ids.size() == 0) { + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true); } } - - // Clear out the old directories to watch for changes and add the new ones - auto watch_dirs = watcher->directories(); - if (!watch_dirs.isEmpty()) { - watcher->removePaths(watch_dirs); - } - // Workaround: Add the watch paths in chunks to allow the gui to refresh - // This prevents the UI from stalling when a large number of watch paths are added - // Also artificially caps the watcher to a certain number of directories - constexpr int LIMIT_WATCH_DIRECTORIES = 5000; - constexpr int SLICE_SIZE = 25; - int len = (std::min)(static_cast(watch_list.size()), LIMIT_WATCH_DIRECTORIES); - - // Block signals to prevent the watcher from triggering a refresh while we are adding paths. - // This fixes a refresh loop on macOS. -#ifdef __APPLE__ - const bool old_signals_blocked = watcher->blockSignals(true); -#endif - - for (int i = 0; i < len; i += SLICE_SIZE) { - auto chunk = watch_list.mid(i, SLICE_SIZE); - if (!chunk.isEmpty()) { - watcher->addPaths(chunk); - } - QCoreApplication::processEvents(); - } - -#ifdef __APPLE__ - watcher->blockSignals(old_signals_blocked); -#endif - m_currentView->setEnabled(true); - - int children_total = 0; - for (int i = 1; i < item_model->rowCount() - 1; ++i) { - children_total += item_model->item(i, 0)->rowCount(); - } - search_field->setFilterResult(children_total, children_total); - if (children_total > 0) { - search_field->setFocus(); - } - item_model->sort(tree_view->header()->sortIndicatorSection(), - tree_view->header()->sortIndicatorOrder()); - - emit PopulatingCompleted(); } void GameList::PopupContextMenu(const QPoint& menu_location) { @@ -747,7 +442,6 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri QAction* verify_integrity = context_menu.addAction(tr("Verify Integrity")); QAction* copy_tid = context_menu.addAction(tr("Copy Title ID to Clipboard")); QAction* navigate_to_gamedb_entry = context_menu.addAction(tr("Navigate to GameDB entry")); -// TODO: Implement shortcut creation for macOS #if !defined(__APPLE__) QMenu* shortcut_menu = context_menu.addMenu(tr("Create Shortcut")); QAction* create_desktop_shortcut = shortcut_menu->addAction(tr("Add to Desktop")); @@ -769,8 +463,9 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri remove_vk_shader_cache->setVisible(program_id != 0); remove_shader_cache->setVisible(program_id != 0); remove_all_content->setVisible(program_id != 0); - auto it = FindMatchingCompatibilityEntry(compatibility_list, program_id); - navigate_to_gamedb_entry->setVisible(it != compatibility_list.end() && program_id != 0); + auto& compat_list = item_model->GetCompatibilityList(); + auto it = FindMatchingCompatibilityEntry(compat_list, program_id); + navigate_to_gamedb_entry->setVisible(it != compat_list.end() && program_id != 0); connect(favorite, &QAction::triggered, this, [this, program_id]() { ToggleFavorite(program_id); }); @@ -779,8 +474,9 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri }); connect(start_game, &QAction::triggered, this, [this, path]() { emit BootGame(QString::fromStdString(path), StartGameType::Normal); }); - connect(start_game_global, &QAction::triggered, this, - [this, path]() { emit BootGame(QString::fromStdString(path), StartGameType::Global); }); + connect(start_game_global, &QAction::triggered, this, [this, path]() { + emit BootGame(QString::fromStdString(path), StartGameType::Global); + }); connect(open_mod_location, &QAction::triggered, this, [this, program_id, path]() { emit OpenFolderRequested(program_id, GameListOpenTarget::ModData, path); }); @@ -831,9 +527,8 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri connect(copy_tid, &QAction::triggered, this, [this, program_id]() { emit CopyTIDRequested(program_id); }); connect(navigate_to_gamedb_entry, &QAction::triggered, this, [this, program_id]() { - emit NavigateToGamedbEntryRequested(program_id, compatibility_list); + emit NavigateToGamedbEntryRequested(program_id, item_model->GetCompatibilityList()); }); -// TODO: Implement shortcut creation for macOS #if !defined(__APPLE__) connect(create_desktop_shortcut, &QAction::triggered, this, [this, program_id, path]() { emit CreateShortcut(program_id, path, QtCommon::Game::ShortcutTarget::Desktop); @@ -885,32 +580,26 @@ void GameList::AddPermDirPopup(QMenu& context_menu, QModelIndex selected) { connect(move_up, &QAction::triggered, this, [this, selected, row, game_dir_index] { const int other_index = selected.sibling(row - 1, 0).data(GameListDir::GameDirRole).toInt(); - // swap the items in the settings std::swap(UISettings::values.game_dirs[game_dir_index], UISettings::values.game_dirs[other_index]); - // swap the indexes held by the QVariants item_model->setData(selected, QVariant(other_index), GameListDir::GameDirRole); item_model->setData(selected.sibling(row - 1, 0), QVariant(game_dir_index), GameListDir::GameDirRole); - // move the treeview items - QList item = item_model->takeRow(row); - item_model->invisibleRootItem()->insertRow(row - 1, item); + QList items = item_model->takeRow(row); + item_model->invisibleRootItem()->insertRow(row - 1, items); tree_view->setExpanded(selected.sibling(row - 1, 0), UISettings::values.game_dirs[other_index].expanded); }); connect(move_down, &QAction::triggered, this, [this, selected, row, game_dir_index] { const int other_index = selected.sibling(row + 1, 0).data(GameListDir::GameDirRole).toInt(); - // swap the items in the settings std::swap(UISettings::values.game_dirs[game_dir_index], UISettings::values.game_dirs[other_index]); - // swap the indexes held by the QVariants item_model->setData(selected, QVariant(other_index), GameListDir::GameDirRole); item_model->setData(selected.sibling(row + 1, 0), QVariant(game_dir_index), GameListDir::GameDirRole); - // move the treeview items - const QList item = item_model->takeRow(row); - item_model->invisibleRootItem()->insertRow(row + 1, item); + const QList items = item_model->takeRow(row); + item_model->invisibleRootItem()->insertRow(row + 1, items); tree_view->setExpanded(selected.sibling(row + 1, 0), UISettings::values.game_dirs[other_index].expanded); }); @@ -925,58 +614,12 @@ void GameList::AddFavoritesPopup(QMenu& context_menu) { QAction* clear = context_menu.addAction(tr("Clear")); connect(clear, &QAction::triggered, this, [this] { - for (const auto id : std::as_const(UISettings::values.favorited_ids)) { - RemoveFavorite(id); - } UISettings::values.favorited_ids.clear(); + item_model->invisibleRootItem()->child(0)->removeRows(0, item_model->invisibleRootItem()->child(0)->rowCount()); tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true); }); } -void GameList::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 GameList::changeEvent(QEvent* event) { if (event->type() == QEvent::LanguageChange) { RetranslateUI(); @@ -986,254 +629,11 @@ void GameList::changeEvent(QEvent* event) { } void GameList::RetranslateUI() { - item_model->setHeaderData(COLUMN_NAME, Qt::Horizontal, tr("Name")); - item_model->setHeaderData(COLUMN_COMPATIBILITY, Qt::Horizontal, tr("Compatibility")); - item_model->setHeaderData(COLUMN_ADD_ONS, Qt::Horizontal, tr("Add-ons")); - item_model->setHeaderData(COLUMN_FILE_TYPE, Qt::Horizontal, tr("File type")); - item_model->setHeaderData(COLUMN_SIZE, Qt::Horizontal, tr("Size")); - item_model->setHeaderData(COLUMN_PLAY_TIME, Qt::Horizontal, tr("Play time")); -} - -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")); -} - -QStandardItemModel* GameList::GetModel() const { - return item_model; -} - -void GameList::UpdateIconSize() { - // Update sizes and stuff for the list view - const u32 icon_size = UISettings::values.game_icon_size.GetValue(); - - int heightMargin = 0; - int widthMargin = 80; - - if (UISettings::values.show_game_name) { - // the scaling on the card is kinda abysmal. - // TODO(crueter): refactor - 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; - } - - // "auto" resize // - const int view_width = list_view->viewport()->width(); - - // Tiny space padding to prevent the list view from forcing its own resize operation. - const double spacing = 0.01; - const int min_item_width = icon_size + widthMargin; - - // And now stretch it a bit to fill out remaining space. - // Not perfect but works well enough for now - int columns = std::max(1, (view_width - 16) / min_item_width); - int stretched_width = ((view_width) - (spacing * (columns - 1))) / columns; - - // only updates things if grid size is changed - QSize grid_size(stretched_width, icon_size + heightMargin); - if (list_view->gridSize() != grid_size) { - list_view->setUpdatesEnabled(false); - - list_view->setGridSize(grid_size); - m_gameCard->setSize(grid_size, stretched_width - min_item_width, columns); - - list_view->setUpdatesEnabled(true); - } -} - -void GameList::PopulateAsync(QVector& game_dirs) { - m_currentView->setEnabled(false); - - // Update the columns in case UISettings has changed - tree_view->setColumnHidden(COLUMN_ADD_ONS, !UISettings::values.show_add_ons); - tree_view->setColumnHidden(COLUMN_COMPATIBILITY, !UISettings::values.show_compat); - tree_view->setColumnHidden(COLUMN_FILE_TYPE, !UISettings::values.show_types); - tree_view->setColumnHidden(COLUMN_SIZE, !UISettings::values.show_size); - tree_view->setColumnHidden(COLUMN_PLAY_TIME, !UISettings::values.show_play_time); - - if (!m_isTreeMode) - UpdateIconSize(); - - // Cancel any existing worker. - current_worker.reset(); - - // Delete any rows that might already exist if we're repopulating - item_model->removeRows(0, item_model->rowCount()); - search_field->clear(); - - current_worker = std::make_unique(vfs, provider, game_dirs, compatibility_list, - play_time_manager, system); - - // Get events from the worker as data becomes available - connect(current_worker.get(), &GameListWorker::DataAvailable, this, &GameList::WorkerEvent, - Qt::QueuedConnection); - - QThreadPool::globalInstance()->start(current_worker.get()); -} - -void GameList::SaveInterfaceLayout() { - UISettings::values.gamelist_header_state = tree_view->header()->saveState(); -} - -void GameList::LoadInterfaceLayout() { - auto* header = tree_view->header(); - - if (header->restoreState(UISettings::values.gamelist_header_state)) - return; - - // We are using the name column to display icons and titles - // so make it as large as possible as default. - - // TODO(crueter): width() is not initialized yet, so use a sane default value - header->resizeSection(COLUMN_NAME, 840); -} - -void GameList::RefreshGameDirectory() { - // Reset the externals watcher whenever the game list is reloaded, - // primarily ensures that new titles and external dirs are caught. - 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 GameList::RefreshExternalContent() { - // TODO: Explore the possibility of only resetting the metadata cache for that specific game. - 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 GameList::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 GameList::ToggleFavorite(u64 program_id) { - if (!UISettings::values.favorited_ids.contains(program_id)) { - tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), - !search_field->filterText().isEmpty()); - UISettings::values.favorited_ids.append(program_id); - AddFavorite(program_id); - item_model->sort(tree_view->header()->sortIndicatorSection(), - tree_view->header()->sortIndicatorOrder()); - } else { - UISettings::values.favorited_ids.removeOne(program_id); - RemoveFavorite(program_id); - if (UISettings::values.favorited_ids.size() == 0) { - tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true); - } - } - emit SaveConfig(); -} - -void GameList::AddFavorite(u64 program_id) { - auto* favorites_row = item_model->item(0); - - for (int i = 1; i < item_model->rowCount() - 1; i++) { - const auto* folder = item_model->item(i); - for (int j = 0; j < folder->rowCount(); j++) { - if (folder->child(j)->data(GameListItemPath::ProgramIdRole).toULongLong() == - program_id) { - QList 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 GameList::RemoveFavorite(u64 program_id) { - auto* favorites_row = item_model->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; - } - } -} - -GameListPlaceholder::GameListPlaceholder(MainWindow* parent) : QWidget{parent} { - connect(parent, &MainWindow::UpdateThemedIcons, this, - &GameListPlaceholder::onUpdateThemedIcons); - - layout = new QVBoxLayout; - image = new QLabel; - text = new QLabel; - layout->setAlignment(Qt::AlignCenter); - image->setPixmap(QIcon::fromTheme(QStringLiteral("plus_folder")).pixmap(200)); - - RetranslateUI(); - QFont font = text->font(); - font.setPointSize(20); - text->setFont(font); - text->setAlignment(Qt::AlignHCenter); - image->setAlignment(Qt::AlignHCenter); - - layout->addWidget(image); - layout->addWidget(text); - setLayout(layout); -} - -GameListPlaceholder::~GameListPlaceholder() = default; - -void GameListPlaceholder::onUpdateThemedIcons() { - image->setPixmap(QIcon::fromTheme(QStringLiteral("plus_folder")).pixmap(200)); -} - -void GameListPlaceholder::mouseDoubleClickEvent(QMouseEvent* event) { - emit GameListPlaceholder::AddDirectory(); + item_model->RetranslateUI(); } void GameList::SetupScrollAnimation() { auto setup = [this](QVariantAnimation* anim, QScrollBar* bar) { - // animation handles moving the bar instead of Qt's built in crap anim->setEasingCurve(QEasingCurve::OutCubic); anim->setDuration(200); connect(anim, &QVariantAnimation::valueChanged, this, @@ -1246,8 +646,8 @@ void GameList::SetupScrollAnimation() { setup(vertical_scroll, tree_view->verticalScrollBar()); setup(horizontal_scroll, tree_view->horizontalScrollBar()); - setup(vertical_scroll, list_view->verticalScrollBar()); - setup(horizontal_scroll, list_view->horizontalScrollBar()); + setup(vertical_scroll, grid_view->verticalScrollBar()); + setup(horizontal_scroll, grid_view->horizontalScrollBar()); } bool GameList::eventFilter(QObject* obj, QEvent* event) { @@ -1259,13 +659,11 @@ bool GameList::eventFilter(QObject* obj, QEvent* event) { int deltaX = wheelEvent->angleDelta().x(); int deltaY = wheelEvent->angleDelta().y(); - // if shift is held do a horizontal scroll if (horizontal && deltaY != 0 && deltaX == 0) { deltaX = deltaY; deltaY = 0; } - // TODO(crueter): dedup this if (deltaY != 0) { if (vertical_scroll->state() == QAbstractAnimation::Stopped) vertical_scroll_target = m_currentView->verticalScrollBar()->value(); @@ -1300,7 +698,6 @@ bool GameList::eventFilter(QObject* obj, QEvent* event) { if (obj == m_currentView->viewport() && event->type() == QEvent::MouseButtonPress) { QMouseEvent* mouseEvent = static_cast(event); - // if the user clicks outside of the list, deselect the current item. QModelIndex index = m_currentView->indexAt(mouseEvent->pos()); if (!index.isValid()) { m_currentView->selectionModel()->clearSelection(); @@ -1308,14 +705,46 @@ bool GameList::eventFilter(QObject* obj, QEvent* event) { } } - if (obj == list_view->viewport() && event->type() == QEvent::Resize) { - UpdateIconSize(); + if (obj == grid_view->viewport() && event->type() == QEvent::Resize) { + grid_view->UpdateIconSize(); return true; } return QWidget::eventFilter(obj, event); } +GameListPlaceholder::GameListPlaceholder(MainWindow* parent) : QWidget{parent} { + connect(parent, &MainWindow::UpdateThemedIcons, this, + &GameListPlaceholder::onUpdateThemedIcons); + + layout = new QVBoxLayout; + image = new QLabel; + text = new QLabel; + layout->setAlignment(Qt::AlignCenter); + image->setPixmap(QIcon::fromTheme(QStringLiteral("plus_folder")).pixmap(200)); + + RetranslateUI(); + QFont font = text->font(); + font.setPointSize(20); + text->setFont(font); + text->setAlignment(Qt::AlignHCenter); + image->setAlignment(Qt::AlignHCenter); + + layout->addWidget(image); + layout->addWidget(text); + setLayout(layout); +} + +GameListPlaceholder::~GameListPlaceholder() = default; + +void GameListPlaceholder::onUpdateThemedIcons() { + image->setPixmap(QIcon::fromTheme(QStringLiteral("plus_folder")).pixmap(200)); +} + +void GameListPlaceholder::mouseDoubleClickEvent(QMouseEvent* event) { + emit GameListPlaceholder::AddDirectory(); +} + void GameListPlaceholder::changeEvent(QEvent* event) { if (event->type() == QEvent::LanguageChange) { RetranslateUI(); diff --git a/src/yuzu/game/game_list.h b/src/yuzu/game/game_list.h index 6cf857c4e1..de0db789aa 100644 --- a/src/yuzu/game/game_list.h +++ b/src/yuzu/game/game_list.h @@ -6,14 +6,12 @@ #pragma once -#include #include #include #include #include #include #include -#include #include #include #include @@ -28,21 +26,22 @@ class QVariantAnimation; -class QListView; - class GameCard; -namespace Core { -class System; -} - +class GameListModel; +class GameTree; +class GameGrid; +class GameListSearchField; class ControllerNavigation; class GameListWorker; -class GameListSearchField; class GameListDir; class MainWindow; enum class AmLaunchType; enum class StartGameType; +namespace Core { +class System; +} + namespace FileSys { class ManualContentProvider; class VfsFilesystem; @@ -62,16 +61,6 @@ 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 vfs_, FileSys::ManualContentProvider* provider_, PlayTime::PlayTimeManager& play_time_manager_, Core::System& system_, @@ -95,13 +84,11 @@ public: /// Disables events from the emulated controller void UnloadController(); - bool IsTreeMode(); void ResetViewMode(); public slots: void RefreshGameDirectory(); void RefreshExternalContent(); - void ResetExternalWatcher(); signals: void BootGame(const QString& game_path, StartGameType type); @@ -130,27 +117,20 @@ signals: void SaveConfig(); private slots: - void OnItemExpanded(const QModelIndex& item); void OnTextChanged(const QString& new_text); void OnFilterCloseClicked(); void OnUpdateThemedIcons(); - - void UpdateIconSize(); + void OnPopulatingCompleted(const QStringList& watch_list); private: - friend class GameListWorker; - void WorkerEvent(); + void SetupViews(); + void SetupScrollAnimation(); + bool eventFilter(QObject* obj, QEvent* event) override; + void changeEvent(QEvent*) override; - void AddDirEntry(GameListDir* entry_items); - void AddEntry(const QList& 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); @@ -158,41 +138,32 @@ private: void AddPermDirPopup(QMenu& context_menu, QModelIndex selected); void AddFavoritesPopup(QMenu& context_menu); - void changeEvent(QEvent*) override; void RetranslateUI(); + friend class GameListSearchField; + std::shared_ptr vfs; FileSys::ManualContentProvider* provider; - GameListSearchField* search_field; + GameListSearchField* search_field = nullptr; MainWindow* main_window = nullptr; QVBoxLayout* layout = nullptr; - QTreeView* tree_view = nullptr; - QListView* list_view = nullptr; - GameCard* m_gameCard = nullptr; + GameTree* tree_view = nullptr; + GameGrid* grid_view = nullptr; + GameListModel* item_model = nullptr; - QStandardItemModel* item_model = nullptr; - std::unique_ptr 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; + QAbstractItemView* m_currentView = nullptr; }; class GameListPlaceholder : public QWidget { diff --git a/src/yuzu/game/game_tree.cpp b/src/yuzu/game/game_tree.cpp new file mode 100644 index 0000000000..f670d03dc6 --- /dev/null +++ b/src/yuzu/game/game_tree.cpp @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include + +#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(); + 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(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); +} diff --git a/src/yuzu/game/game_tree.h b/src/yuzu/game/game_tree.h new file mode 100644 index 0000000000..b5eebdb9dd --- /dev/null +++ b/src/yuzu/game/game_tree.h @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +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); +}; diff --git a/src/yuzu/game/search_field.cpp b/src/yuzu/game/search_field.cpp new file mode 100644 index 0000000000..c2d4893742 --- /dev/null +++ b/src/yuzu/game/search_field.cpp @@ -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 +#include + +// 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(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")); +} diff --git a/src/yuzu/game/search_field.h b/src/yuzu/game/search_field.h new file mode 100644 index 0000000000..18f97300f7 --- /dev/null +++ b/src/yuzu/game/search_field.h @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +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; +}; diff --git a/src/yuzu/multiplayer/chat_room.cpp b/src/yuzu/multiplayer/chat_room.cpp index e274aaf62d..c28ae31bee 100644 --- a/src/yuzu/multiplayer/chat_room.cpp +++ b/src/yuzu/multiplayer/chat_room.cpp @@ -20,7 +20,7 @@ #include "common/logging.h" #include "network/announce_multiplayer_session.h" #include "ui_chat_room.h" -#include "yuzu/game/game_list_p.h" +#include "qt_common/game_list/game_list_p.h" #include "yuzu/multiplayer/chat_room.h" #include "yuzu/multiplayer/message.h" #ifdef ENABLE_WEB_SERVICE diff --git a/src/yuzu/multiplayer/client_room.cpp b/src/yuzu/multiplayer/client_room.cpp index 391ca56c05..31e5e20e52 100644 --- a/src/yuzu/multiplayer/client_room.cpp +++ b/src/yuzu/multiplayer/client_room.cpp @@ -14,7 +14,7 @@ #include "common/logging.h" #include "network/announce_multiplayer_session.h" #include "ui_client_room.h" -#include "yuzu/game/game_list_p.h" +#include "qt_common/game_list/game_list_p.h" #include "yuzu/multiplayer/client_room.h" #include "yuzu/multiplayer/message.h" #include "yuzu/multiplayer/moderation_dialog.h" diff --git a/src/yuzu/multiplayer/host_room.cpp b/src/yuzu/multiplayer/host_room.cpp index 2a5530e02c..ac6017e71f 100644 --- a/src/yuzu/multiplayer/host_room.cpp +++ b/src/yuzu/multiplayer/host_room.cpp @@ -20,7 +20,7 @@ #include "network/announce_multiplayer_session.h" #include "qt_common/config/uisettings.h" #include "ui_host_room.h" -#include "yuzu/game/game_list_p.h" +#include "qt_common/game_list/game_list_p.h" #include "yuzu/main_window.h" #include "yuzu/multiplayer/host_room.h" #include "yuzu/multiplayer/message.h" diff --git a/src/yuzu/multiplayer/lobby.cpp b/src/yuzu/multiplayer/lobby.cpp index 8e9ff6563f..d9e752896a 100644 --- a/src/yuzu/multiplayer/lobby.cpp +++ b/src/yuzu/multiplayer/lobby.cpp @@ -15,7 +15,7 @@ #include "network/network.h" #include "qt_common/config/uisettings.h" #include "ui_lobby.h" -#include "yuzu/game/game_list_p.h" +#include "qt_common/game_list/game_list_p.h" #include "yuzu/main_window.h" #include "yuzu/multiplayer/client_room.h" #include "yuzu/multiplayer/lobby.h" diff --git a/src/yuzu/util/util.cpp b/src/yuzu/util/util.cpp index e37d9d03e4..eb6583b358 100644 --- a/src/yuzu/util/util.cpp +++ b/src/yuzu/util/util.cpp @@ -4,14 +4,11 @@ // SPDX-FileCopyrightText: 2015 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later -#include #include #include "applets/qt_profile_select.h" -#include "common/logging.h" #include "core/frontend/applets/profile_select.h" #include "core/hle/service/acc/profile_manager.h" -#include "frontend_common/data_manager.h" #include "qt_common/qt_common.h" #include "yuzu/util/util.h" @@ -28,21 +25,6 @@ QFont GetMonospaceFont() { return font; } -QString ReadableByteSize(qulonglong size) { - return QString::fromStdString(FrontendCommon::DataManager::ReadableBytesSize(size)); -} - -QPixmap CreateCirclePixmapFromColor(const QColor& color) { - QPixmap circle_pixmap(16, 16); - circle_pixmap.fill(Qt::transparent); - QPainter painter(&circle_pixmap); - painter.setRenderHint(QPainter::Antialiasing); - painter.setPen(color); - painter.setBrush(color); - painter.drawEllipse({circle_pixmap.width() / 2.0, circle_pixmap.height() / 2.0}, 7.0, 7.0); - return circle_pixmap; -} - const std::optional GetProfileID() { // if there's only a single profile, the user probably wants to use that... right? const auto& profiles = QtCommon::system->GetProfileManager().FindExistingProfileUUIDs(); diff --git a/src/yuzu/util/util.h b/src/yuzu/util/util.h index 9c3d5aa84a..2e4e325e22 100644 --- a/src/yuzu/util/util.h +++ b/src/yuzu/util/util.h @@ -13,16 +13,6 @@ /// Returns a QFont object appropriate to use as a monospace font for debugging widgets, etc. [[nodiscard]] QFont GetMonospaceFont(); -/// Convert a size in bytes into a readable format (KiB, MiB, etc.) -[[nodiscard]] QString ReadableByteSize(qulonglong size); - -/** - * Creates a circle pixmap from a specified color - * @param color The color the pixmap shall have - * @return QPixmap circle pixmap - */ -[[nodiscard]] QPixmap CreateCirclePixmapFromColor(const QColor& color); - /** * Prompt the user for a profile ID. If there is only one valid profile, returns that profile. * @return The selected profile, or an std::nullopt if none were selected