diff --git a/src/common/settings_enums.h b/src/common/settings_enums.h index 30d075565b..33c553dc3c 100644 --- a/src/common/settings_enums.h +++ b/src/common/settings_enums.h @@ -155,6 +155,7 @@ ENUM(GpuUnswizzleChunk, VeryLow, Low, Normal, Medium, High) ENUM(TemperatureUnits, Celsius, Fahrenheit) ENUM(ExtendedDynamicState, Disabled, EDS1, EDS2, EDS3); ENUM(GpuLogLevel, Off, Errors, Standard, Verbose, All) +ENUM(GameListMode, TreeView, GridView); template inline std::string_view CanonicalizeEnum(Type id) { diff --git a/src/qt_common/config/shared_translation.cpp b/src/qt_common/config/shared_translation.cpp index 0d15f9065c..5d4185b47d 100644 --- a/src/qt_common/config/shared_translation.cpp +++ b/src/qt_common/config/shared_translation.cpp @@ -766,6 +766,12 @@ std::unique_ptr ComboboxEnumeration(QObject* parent) PAIR(ExtendedDynamicState, EDS3, tr("ExtendedDynamicState 3")), }}); + translations->insert({Settings::EnumMetadata::Index(), + { + PAIR(GameListMode, TreeView, tr("Tree View")), + PAIR(GameListMode, GridView, tr("Grid View")), + }}); + #undef PAIR #undef CTX_PAIR diff --git a/src/qt_common/config/uisettings.h b/src/qt_common/config/uisettings.h index 679d00782d..89e3833508 100644 --- a/src/qt_common/config/uisettings.h +++ b/src/qt_common/config/uisettings.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2016 Citra Emulator Project @@ -210,6 +210,8 @@ struct Values { Setting folder_icon_size{linkage, 48, "folder_icon_size", Category::UiGameList}; Setting row_1_text_id{linkage, 3, "row_1_text_id", Category::UiGameList}; Setting row_2_text_id{linkage, 2, "row_2_text_id", Category::UiGameList}; + Setting game_list_mode{linkage, Settings::GameListMode::TreeView, "game_list_mode", Category::UiGameList}; + std::atomic_bool is_game_list_reload_pending{false}; Setting cache_game_list{linkage, true, "cache_game_list", Category::UiGameList}; Setting favorites_expanded{linkage, true, "favorites_expanded", Category::UiGameList}; diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt index ba2b5b3927..dad32f2316 100644 --- a/src/yuzu/CMakeLists.txt +++ b/src/yuzu/CMakeLists.txt @@ -156,11 +156,14 @@ add_executable(yuzu debugger/controller.cpp debugger/controller.h - game_list.cpp - game_list.h - game_list_p.h - game_list_worker.cpp - game_list_worker.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 + hotkeys.cpp hotkeys.h install_dialog.cpp @@ -234,7 +237,7 @@ add_executable(yuzu data_dialog.h data_dialog.cpp data_dialog.ui data_widget.ui ryujinx_dialog.h ryujinx_dialog.cpp ryujinx_dialog.ui - main_window.h main_window.cpp + main_window.h main_window.cpp main.ui configuration/system/new_user_dialog.h configuration/system/new_user_dialog.cpp configuration/system/new_user_dialog.ui configuration/system/profile_avatar_dialog.h configuration/system/profile_avatar_dialog.cpp diff --git a/src/yuzu/game/game_card.cpp b/src/yuzu/game/game_card.cpp new file mode 100644 index 0000000000..d172d3e535 --- /dev/null +++ b/src/yuzu/game/game_card.cpp @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include "game_card.h" +#include "qt_common/config/uisettings.h" + +GameCard::GameCard(QObject* parent) : QStyledItemDelegate{parent} { + setObjectName("GameCard"); +} + +void GameCard::paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const { + if (!index.isValid()) + return; + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing); + + // padding + QRect cardRect = option.rect.adjusted(4, 4, -4, -4); + + // colors + QPalette palette = option.palette; + QColor backgroundColor = palette.window().color(); + QColor borderColor = palette.dark().color(); + QColor textColor = palette.text().color(); + + // if it's selected add a blue background + if (option.state & QStyle::State_Selected) { + backgroundColor = palette.highlight().color(); + borderColor = palette.highlight().color().lighter(150); + textColor = palette.highlightedText().color(); + } else if (option.state & QStyle::State_MouseOver) { + backgroundColor = backgroundColor.lighter(110); + } + + // bg + painter->setBrush(backgroundColor); + painter->setPen(QPen(borderColor, 1)); + painter->drawRoundedRect(cardRect, 10, 10); + + static constexpr const int padding = 10; + + // icon + int _iconsize = UISettings::values.game_icon_size.GetValue(); + QSize iconSize(_iconsize, _iconsize); + QPixmap iconPixmap = index.data(Qt::DecorationRole).value(); + + QRect iconRect; + if (!iconPixmap.isNull()) { + QSize scaledSize = iconPixmap.size(); + scaledSize.scale(iconSize, Qt::KeepAspectRatio); + + int x = cardRect.left() + (cardRect.width() - scaledSize.width()) / 2; + int y = cardRect.top() + padding; + + iconRect = QRect(x, y, scaledSize.width(), scaledSize.height()); + + painter->setRenderHint(QPainter::SmoothPixmapTransform, true); + painter->drawPixmap(iconRect, iconPixmap); + } else { + // if there is no icon just draw a blank rect + iconRect = QRect(cardRect.left() + padding, + cardRect.top() + padding, + _iconsize, _iconsize); + } + + // if "none" is selected, pretend there's a + _iconsize = _iconsize ? _iconsize : 96; + + // padding + text + QRect textRect = cardRect; + textRect.setTop(iconRect.bottom() + 8); + textRect.adjust(padding, 0, -padding, -padding); + + // We are already crammed on space, ignore the row 2 + QString title = index.data(Qt::DisplayRole).toString(); + title = title.split(QLatin1Char('\n')).first(); + + // now draw text + painter->setPen(textColor); + QFont font = option.font; + font.setBold(true); + + // TODO(crueter): fix this abysmal scaling + // If "none" is selected, then default to 8.5 point font. + font.setPointSize(1 + std::max(7.0, _iconsize ? std::sqrt(_iconsize * 0.6) : 7.5)); + + // TODO(crueter): elide mode + painter->setFont(font); + + painter->drawText(textRect, Qt::AlignHCenter | Qt::AlignTop | Qt::TextWordWrap, title); + + painter->restore(); +} + +QSize GameCard::sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const { + return m_size; +} + +void GameCard::setSize(const QSize& newSize) { + m_size = newSize; +} diff --git a/src/yuzu/game/game_card.h b/src/yuzu/game/game_card.h new file mode 100644 index 0000000000..3c695c9047 --- /dev/null +++ b/src/yuzu/game/game_card.h @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +/** + * A stylized "card"-like delegate for the game grid view. + * Adapted from QML + */ +class GameCard : public QStyledItemDelegate { + Q_OBJECT +public: + explicit GameCard(QObject* parent = nullptr); + + void paint(QPainter *painter, + const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + + QSize sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + void setSize(const QSize& newSize); + +private: + QSize m_size; +}; diff --git a/src/yuzu/game_list.cpp b/src/yuzu/game/game_list.cpp similarity index 82% rename from src/yuzu/game_list.cpp rename to src/yuzu/game/game_list.cpp index d206ab096b..515fed1a8d 100644 --- a/src/yuzu/game_list.cpp +++ b/src/yuzu/game/game_list.cpp @@ -9,29 +9,30 @@ #include #include #include +#include #include -#include #include +#include #include #include #include #include -#include -#include -#include -#include +#include +#include +#include #include "common/common_types.h" #include "common/logging/log.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/util/game.h" #include "yuzu/compatibility_list.h" -#include "yuzu/game_list.h" -#include "yuzu/game_list_p.h" -#include "yuzu/game_list_worker.h" +#include "yuzu/game/game_list.h" +#include "yuzu/game/game_list_p.h" +#include "yuzu/game/game_list_worker.h" #include "yuzu/main_window.h" #include "yuzu/util/controller_navigation.h" #include "qt_common/qt_common.h" @@ -198,25 +199,56 @@ 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 - if (edit_filter_text.isEmpty()) { - tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), - UISettings::values.favorited_ids.size() == 0); + + // 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; - tree_view->setRowHidden(j, folder_index, false); + hide(j, false, folder_index); } } search_field->setFilterResult(children_total, children_total); } else { - tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true); - int result_count = 0; + 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(); @@ -245,10 +277,10 @@ void GameList::OnTextChanged(const QString& new_text) { file_title; if (ContainsAllWords(file_name, edit_filter_text) || (file_program_id.size() == 16 && file_program_id.contains(edit_filter_text))) { - tree_view->setRowHidden(j, folder_index, false); + hide(j, false, folder_index); ++result_count; } else { - tree_view->setRowHidden(j, folder_index, true); + hide(j, true, folder_index); } } } @@ -334,28 +366,21 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid this->main_window = parent; layout = new QVBoxLayout; - tree_view = new QTreeView; + tree_view = new QTreeView(this); + list_view = new QListView(this); + m_gameCard = new GameCard(this); + + list_view->setItemDelegate(m_gameCard); + 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_view->viewport()->installEventFilter(this); - - // touch gestures - tree_view->viewport()->grabGesture(Qt::SwipeGesture); - tree_view->viewport()->grabGesture(Qt::PanGesture); - - // TODO: touch? - QScroller::grabGesture(tree_view->viewport(), QScroller::LeftMouseButtonGesture); - - auto scroller = QScroller::scroller(tree_view->viewport()); - QScrollerProperties props; - props.setScrollMetric(QScrollerProperties::HorizontalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff); - props.setScrollMetric(QScrollerProperties::VerticalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff); - scroller->setScrollerProperties(props); + // tree tree_view->setAlternatingRowColors(true); tree_view->setSelectionMode(QHeaderView::SingleSelection); tree_view->setSelectionBehavior(QHeaderView::SelectRows); @@ -367,6 +392,24 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid tree_view->setAttribute(Qt::WA_AcceptTouchEvents, true); tree_view->setStyleSheet(QStringLiteral("QTreeView{ border: none; }")); + // list view setup + list_view->setViewMode(QListView::IconMode); + list_view->setResizeMode(QListView::Adjust); + list_view->setUniformItemSizes(false); + list_view->setSelectionMode(QAbstractItemView::SingleSelection); + list_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + list_view->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel); + list_view->setEditTriggers(QAbstractItemView::NoEditTriggers); + list_view->setContextMenuPolicy(Qt::CustomContextMenu); + list_view->setGridSize(QSize(140, 160)); + m_gameCard->setSize(list_view->gridSize()); + + 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(); @@ -376,8 +419,13 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid 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(tree_view, &QTreeView::expanded, this, &GameList::OnItemExpanded); connect(tree_view, &QTreeView::collapsed, this, &GameList::OnItemExpanded); connect(controller_navigation, &ControllerNavigation::TriggerKeyboardEvent, this, @@ -391,6 +439,7 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid } QKeyEvent* event = new QKeyEvent(QEvent::KeyPress, key, Qt::NoModifier); QCoreApplication::postEvent(tree_view, event); + QCoreApplication::postEvent(list_view, event); }); // We must register all custom types with the Qt Automoc system so that we are able to use @@ -401,14 +450,70 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid layout->setSpacing(0); layout->addWidget(tree_view); + layout->addWidget(list_view); layout->addWidget(search_field); setLayout(layout); + + ResetViewMode(); } void GameList::UnloadController() { controller_navigation->UnloadController(); } +bool GameList::IsTreeMode() { + return m_isTreeMode; +} + +void GameList::ResetViewMode() { + auto &setting = UISettings::values.game_list_mode; + bool newTreeMode = false; + + switch (setting.GetValue()) { + case Settings::GameListMode::TreeView: + m_currentView = tree_view; + newTreeMode = true; + + tree_view->setVisible(true); + list_view->setVisible(false); + break; + case Settings::GameListMode::GridView: + m_currentView = list_view; + newTreeMode = false; + + list_view->setVisible(true); + tree_view->setVisible(false); + break; + default: + break; + } + + if (m_isTreeMode != newTreeMode) { + m_isTreeMode = newTreeMode; + + 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); + QScrollerProperties props; + props.setScrollMetric(QScrollerProperties::HorizontalOvershootPolicy, + QScrollerProperties::OvershootAlwaysOff); + props.setScrollMetric(QScrollerProperties::VerticalOvershootPolicy, + QScrollerProperties::OvershootAlwaysOff); + scroller->setScrollerProperties(props); + + RefreshGameDirectory(); + } +} + GameList::~GameList() { UnloadController(); } @@ -432,14 +537,20 @@ void GameList::WorkerEvent() { } void GameList::AddDirEntry(GameListDir* entry_items) { - item_model->invisibleRootItem()->appendRow(entry_items); - tree_view->setExpanded( - entry_items->index(), - UISettings::values.game_dirs[entry_items->data(GameListDir::GameDirRole).toInt()].expanded); + 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); + } } void GameList::AddEntry(const QList& entry_items, GameListDir* parent) { - parent->appendRow(entry_items); + if (!m_isTreeMode) + item_model->invisibleRootItem()->appendRow(entry_items); + else + parent->appendRow(entry_items); } void GameList::ValidateEntry(const QModelIndex& item) { @@ -497,16 +608,18 @@ bool GameList::IsEmpty() const { void GameList::DonePopulating(const QStringList& watch_list) { emit ShowList(!IsEmpty()); - item_model->invisibleRootItem()->appendRow(new GameListAddDir()); - // Add favorites row - item_model->invisibleRootItem()->insertRow(0, new GameListFavorites()); - 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); + if (m_isTreeMode) { + item_model->invisibleRootItem()->appendRow(new GameListAddDir()); + + item_model->invisibleRootItem()->insertRow(0, new GameListFavorites()); + 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); + } } // Clear out the old directories to watch for changes and add the new ones @@ -538,7 +651,8 @@ void GameList::DonePopulating(const QStringList& watch_list) { #ifdef __APPLE__ watcher->blockSignals(old_signals_blocked); #endif - tree_view->setEnabled(true); + 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(); @@ -554,9 +668,18 @@ void GameList::DonePopulating(const QStringList& watch_list) { } void GameList::PopupContextMenu(const QPoint& menu_location) { - QModelIndex item = tree_view->indexAt(menu_location); - if (!item.isValid()) + QModelIndex item = m_currentView->indexAt(menu_location); + if (!item.isValid()) { + if (m_isTreeMode) + return; + + QMenu blank_menu; + QAction *addGameDirAction = blank_menu.addAction(tr("&Add New Game Directory")); + + connect(addGameDirAction, &QAction::triggered, this, &GameList::AddDirectory); + blank_menu.exec(m_currentView->viewport()->mapToGlobal(menu_location)); return; + } const auto selected = item.sibling(item.row(), 0); QMenu context_menu; @@ -580,7 +703,7 @@ void GameList::PopupContextMenu(const QPoint& menu_location) { default: break; } - context_menu.exec(tree_view->viewport()->mapToGlobal(menu_location)); + context_menu.exec(m_currentView->viewport()->mapToGlobal(menu_location)); } void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path) { @@ -641,7 +764,8 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri auto it = FindMatchingCompatibilityEntry(compatibility_list, program_id); navigate_to_gamedb_entry->setVisible(it != compatibility_list.end() && program_id != 0); - connect(favorite, &QAction::triggered, this, [this, program_id]() { ToggleFavorite(program_id); }); + connect(favorite, &QAction::triggered, this, + [this, program_id]() { ToggleFavorite(program_id); }); connect(open_save_location, &QAction::triggered, this, [this, program_id, path]() { emit OpenFolderRequested(program_id, GameListOpenTarget::SaveData, path); }); @@ -661,26 +785,32 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri emit RemoveInstalledEntryRequested(program_id, QtCommon::Game::InstalledEntryType::Update); }); connect(remove_dlc, &QAction::triggered, this, [this, program_id]() { - emit RemoveInstalledEntryRequested(program_id, QtCommon::Game::InstalledEntryType::AddOnContent); + emit RemoveInstalledEntryRequested(program_id, + QtCommon::Game::InstalledEntryType::AddOnContent); }); connect(remove_gl_shader_cache, &QAction::triggered, this, [this, program_id, path]() { - emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::GlShaderCache, path); + emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::GlShaderCache, + path); }); connect(remove_vk_shader_cache, &QAction::triggered, this, [this, program_id, path]() { - emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::VkShaderCache, path); + emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::VkShaderCache, + path); }); connect(remove_shader_cache, &QAction::triggered, this, [this, program_id, path]() { - emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::AllShaderCache, path); + emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::AllShaderCache, + path); }); connect(remove_custom_config, &QAction::triggered, this, [this, program_id, path]() { - emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::CustomConfiguration, path); + emit RemoveFileRequested(program_id, + QtCommon::Game::GameListRemoveTarget::CustomConfiguration, path); }); connect(set_play_time, &QAction::triggered, this, [this, program_id]() { emit SetPlayTimeRequested(program_id); }); connect(remove_play_time_data, &QAction::triggered, this, [this, program_id]() { emit RemovePlayTimeRequested(program_id); }); connect(remove_cache_storage, &QAction::triggered, this, [this, program_id, path] { - emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::CacheStorage, path); + emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::CacheStorage, + path); }); connect(dump_romfs, &QAction::triggered, this, [this, program_id, path]() { emit DumpRomFSRequested(program_id, path, DumpRomFSTarget::Normal); @@ -700,15 +830,16 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri connect(create_desktop_shortcut, &QAction::triggered, this, [this, program_id, path]() { emit CreateShortcut(program_id, path, QtCommon::Game::ShortcutTarget::Desktop); }); - connect(create_applications_menu_shortcut, &QAction::triggered, this, [this, program_id, path]() { - emit CreateShortcut(program_id, path, QtCommon::Game::ShortcutTarget::Applications); - }); + connect(create_applications_menu_shortcut, &QAction::triggered, this, + [this, program_id, path]() { + emit CreateShortcut(program_id, path, QtCommon::Game::ShortcutTarget::Applications); + }); #endif connect(properties, &QAction::triggered, this, [this, path]() { emit OpenPerGameGeneralRequested(path); }); - connect(ryujinx, &QAction::triggered, this, [this, program_id]() { emit LinkToRyujinxRequested(program_id); - }); + connect(ryujinx, &QAction::triggered, this, + [this, program_id]() { emit LinkToRyujinxRequested(program_id); }); }; void GameList::AddCustomDirPopup(QMenu& context_menu, QModelIndex selected) { @@ -816,7 +947,7 @@ void GameList::LoadCompatibilityList() { const QJsonDocument json = QJsonDocument::fromJson(content); const QJsonArray arr = json.array(); - for (const QJsonValue &value : arr) { + for (const QJsonValue& value : arr) { const QJsonObject game = value.toObject(); const QString compatibility_key = QStringLiteral("compatibility"); @@ -828,7 +959,7 @@ void GameList::LoadCompatibilityList() { const QString directory = game[QStringLiteral("directory")].toString(); const QJsonArray ids = game[QStringLiteral("releases")].toArray(); - for (const QJsonValue &id_ref : ids) { + for (const QJsonValue& id_ref : ids) { const QJsonObject id_object = id_ref.toObject(); const QString id = id_object[QStringLiteral("id")].toString(); @@ -872,9 +1003,38 @@ QStandardItemModel* GameList::GetModel() const { return item_model; } -void GameList::PopulateAsync(QVector& game_dirs) -{ - tree_view->setEnabled(false); +void GameList::UpdateIconSize() { + // Update sizes and stuff for the list view + const u32 icon_size = UISettings::values.game_icon_size.GetValue(); + + // the scaling on the card is kinda abysmal. + // TODO(crueter): refactor + int heightMargin = 0; + int widthMargin = 80; + switch (icon_size) { + case 128: + heightMargin = 70; + break; + case 0: + widthMargin = 120; + heightMargin = 120; + break; + case 64: + heightMargin = 80; + break; + case 32: + case 256: + heightMargin = 81; + break; + } + + // TODO(crueter): Auto size + list_view->setGridSize(QSize(icon_size + widthMargin, icon_size + heightMargin)); + m_gameCard->setSize(list_view->gridSize()); +} + +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); @@ -883,6 +1043,8 @@ void GameList::PopulateAsync(QVector& game_dirs) tree_view->setColumnHidden(COLUMN_SIZE, !UISettings::values.show_size); tree_view->setColumnHidden(COLUMN_PLAY_TIME, !UISettings::values.show_play_time); + UpdateIconSize(); + // Cancel any existing worker. current_worker.reset(); @@ -890,12 +1052,8 @@ void GameList::PopulateAsync(QVector& game_dirs) 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); + 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, @@ -1048,9 +1206,8 @@ void GameList::SetupScrollAnimation() { // animation handles moving the bar instead of Qt's built in crap anim->setEasingCurve(QEasingCurve::OutCubic); anim->setDuration(200); - connect(anim, &QVariantAnimation::valueChanged, this, [bar](const QVariant& value) { - bar->setValue(value.toInt()); - }); + connect(anim, &QVariantAnimation::valueChanged, this, + [bar](const QVariant& value) { bar->setValue(value.toInt()); }); }; vertical_scroll = new QVariantAnimation(this); @@ -1058,10 +1215,13 @@ 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()); } bool GameList::eventFilter(QObject* obj, QEvent* event) { - if (obj == tree_view->viewport() && event->type() == QEvent::Wheel) { + if (obj == m_currentView->viewport() && event->type() == QEvent::Wheel) { QWheelEvent* wheelEvent = static_cast(event); bool horizontal = wheelEvent->modifiers() & Qt::ShiftModifier; @@ -1078,26 +1238,28 @@ bool GameList::eventFilter(QObject* obj, QEvent* event) { // TODO(crueter): dedup this if (deltaY != 0) { if (vertical_scroll->state() == QAbstractAnimation::Stopped) - vertical_scroll_target = tree_view->verticalScrollBar()->value(); + vertical_scroll_target = m_currentView->verticalScrollBar()->value(); vertical_scroll_target -= deltaY; - vertical_scroll_target = qBound(0, vertical_scroll_target, tree_view->verticalScrollBar()->maximum()); + vertical_scroll_target = + qBound(0, vertical_scroll_target, m_currentView->verticalScrollBar()->maximum()); vertical_scroll->stop(); - vertical_scroll->setStartValue(tree_view->verticalScrollBar()->value()); + vertical_scroll->setStartValue(m_currentView->verticalScrollBar()->value()); vertical_scroll->setEndValue(vertical_scroll_target); vertical_scroll->start(); } if (deltaX != 0) { if (horizontal_scroll->state() == QAbstractAnimation::Stopped) - horizontal_scroll_target = tree_view->horizontalScrollBar()->value(); + horizontal_scroll_target = m_currentView->horizontalScrollBar()->value(); horizontal_scroll_target -= deltaX; - horizontal_scroll_target = qBound(0, horizontal_scroll_target, tree_view->horizontalScrollBar()->maximum()); + horizontal_scroll_target = + qBound(0, horizontal_scroll_target, m_currentView->horizontalScrollBar()->maximum()); horizontal_scroll->stop(); - horizontal_scroll->setStartValue(tree_view->horizontalScrollBar()->value()); + horizontal_scroll->setStartValue(m_currentView->horizontalScrollBar()->value()); horizontal_scroll->setEndValue(horizontal_scroll_target); horizontal_scroll->start(); } diff --git a/src/yuzu/game_list.h b/src/yuzu/game/game_list.h similarity index 95% rename from src/yuzu/game_list.h rename to src/yuzu/game/game_list.h index 9b00e270cd..7de622b714 100644 --- a/src/yuzu/game_list.h +++ b/src/yuzu/game/game_list.h @@ -17,6 +17,7 @@ #include #include #include +#include #include "common/common_types.h" #include "core/core.h" @@ -26,6 +27,10 @@ #include "frontend_common/play_time_manager.h" class QVariantAnimation; + +class QListView; + +class GameCard; namespace Core { class System; } @@ -92,6 +97,9 @@ public: static const QStringList supported_file_extensions; + bool IsTreeMode(); + void ResetViewMode(); + public slots: void RefreshGameDirectory(); void RefreshExternalContent(); @@ -129,6 +137,8 @@ private slots: void OnFilterCloseClicked(); void OnUpdateThemedIcons(); + void UpdateIconSize(); + private: friend class GameListWorker; void WorkerEvent(); @@ -158,7 +168,11 @@ private: GameListSearchField* search_field; MainWindow* main_window = nullptr; QVBoxLayout* layout = nullptr; + QTreeView* tree_view = nullptr; + QListView *list_view = nullptr; + GameCard *m_gameCard = nullptr; + QStandardItemModel* item_model = nullptr; std::unique_ptr current_worker; QFileSystemWatcher* watcher = nullptr; @@ -178,6 +192,9 @@ private: const PlayTime::PlayTimeManager& play_time_manager; Core::System& system; + + bool m_isTreeMode = true; + QAbstractItemView *m_currentView = tree_view; }; class GameListPlaceholder : public QWidget { diff --git a/src/yuzu/game_list_p.h b/src/yuzu/game/game_list_p.h similarity index 92% rename from src/yuzu/game_list_p.h rename to src/yuzu/game/game_list_p.h index ea11d34865..95f5f7eb78 100644 --- a/src/yuzu/game_list_p.h +++ b/src/yuzu/game/game_list_p.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2015 Citra Emulator Project @@ -75,13 +75,17 @@ public: GameListItemPath() = default; GameListItemPath(const QString& game_path, const std::vector& picture_data, - const QString& game_name, const QString& game_type, u64 program_id) { + const QString& game_name, const QString& game_type, u64 program_id, + u64 play_time) { setData(type(), TypeRole); setData(game_path, FullPathRole); setData(game_name, TitleRole); setData(qulonglong(program_id), ProgramIdRole); setData(game_type, FileTypeRole); + setData(QString::fromStdString(PlayTime::PlayTimeManager::GetReadablePlayTime(play_time)), + Qt::ToolTipRole); + const u32 size = UISettings::values.game_icon_size.GetValue(); QPixmap picture; @@ -111,24 +115,35 @@ public: }}; const auto& row1 = row_data.at(UISettings::values.row_1_text_id.GetValue()); - const int row2_id = UISettings::values.row_2_text_id.GetValue(); + // don't show row 2 on grid view + switch (UISettings::values.game_list_mode.GetValue()) { - if (role == SortRole) { - return row1.toLower(); - } + case Settings::GameListMode::TreeView: { + const int row2_id = UISettings::values.row_2_text_id.GetValue(); - // None - if (row2_id == 4) { - return row1; - } + if (role == SortRole) { + return row1.toLower(); + } - const auto& row2 = row_data.at(row2_id); + // None + if (row2_id == 4) { + return row1; + } - if (row1 == row2) { + const auto& row2 = row_data.at(row2_id); + + if (row1 == row2) { + return row1; + } + + return QStringLiteral("%1\n %2").arg(row1, row2); + } + case Settings::GameListMode::GridView: return row1; + default: + break; } - return QStringLiteral("%1\n %2").arg(row1, row2); } return GameListItem::data(role); @@ -241,7 +256,9 @@ public: void setData(const QVariant& value, int role) override { qulonglong time_seconds = value.toULongLong(); - GameListItem::setData(QString::fromStdString(PlayTime::PlayTimeManager::GetReadablePlayTime(time_seconds)), Qt::DisplayRole); + GameListItem::setData( + QString::fromStdString(PlayTime::PlayTimeManager::GetReadablePlayTime(time_seconds)), + Qt::DisplayRole); GameListItem::setData(value, PlayTimeRole); } diff --git a/src/yuzu/game_list_worker.cpp b/src/yuzu/game/game_list_worker.cpp similarity index 98% rename from src/yuzu/game_list_worker.cpp rename to src/yuzu/game/game_list_worker.cpp index 131d6e6db4..d9c91334e1 100644 --- a/src/yuzu/game_list_worker.cpp +++ b/src/yuzu/game/game_list_worker.cpp @@ -27,9 +27,9 @@ #include "core/file_sys/submission_package.h" #include "core/loader/loader.h" #include "yuzu/compatibility_list.h" -#include "yuzu/game_list.h" -#include "yuzu/game_list_p.h" -#include "yuzu/game_list_worker.h" +#include "yuzu/game/game_list.h" +#include "yuzu/game/game_list_p.h" +#include "yuzu/game/game_list_worker.h" #include "qt_common/config/uisettings.h" namespace { @@ -214,11 +214,14 @@ QList MakeGameListEntry(const std::string& path, QString patch_versions = GetGameListCachedObject(fmt::format("{:016X}", patch.GetTitleID()), "pv.txt", [&patch, &loader] { return FormatPatchNameVersions(patch, loader, loader.IsRomFSUpdatable()); }); + + u64 play_time = play_time_manager.GetPlayTime(program_id); return QList{ - new GameListItemPath(FormatGameName(path), icon, QString::fromStdString(name), file_type_string, program_id), + new GameListItemPath(FormatGameName(path), icon, QString::fromStdString(name), + file_type_string, program_id, play_time), new GameListItem(file_type_string), new GameListItemSize(size), - new GameListItemPlayTime(play_time_manager.GetPlayTime(program_id)), + new GameListItemPlayTime(play_time), new GameListItem(patch_versions), new GameListItemCompat(compatibility), }; diff --git a/src/yuzu/game_list_worker.h b/src/yuzu/game/game_list_worker.h similarity index 97% rename from src/yuzu/game_list_worker.h rename to src/yuzu/game/game_list_worker.h index 1bbb024df3..76153f7917 100644 --- a/src/yuzu/game_list_worker.h +++ b/src/yuzu/game/game_list_worker.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project diff --git a/src/yuzu/main.ui b/src/yuzu/main.ui index 2f968f2b01..54fc778f80 100644 --- a/src/yuzu/main.ui +++ b/src/yuzu/main.ui @@ -105,6 +105,13 @@ &Debugging + + + &Game List Mode + + + + Reset Window Size to &720p @@ -137,6 +144,7 @@ + @@ -562,6 +570,22 @@ &Data Manager + + + true + + + &Tree View + + + + + true + + + &Grid View + + diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index e44696b6a3..def24ef5d3 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -3,6 +3,7 @@ // Qt on macOS doesn't define VMA shit #include +#include "common/settings_enums.h" #include "frontend_common/settings_generator.h" #include "qt_common/qt_string_lookup.h" #if defined(QT_STATICPLUGIN) && !defined(__APPLE__) @@ -25,7 +26,7 @@ #include "install_dialog.h" #include "bootmanager.h" -#include "game_list.h" +#include "yuzu/game/game_list.h" #include "loading_screen.h" #include "ryujinx_dialog.h" #include "set_play_time_dialog.h" @@ -550,6 +551,9 @@ MainWindow::MainWindow(bool has_broken_vulkan) game_list->LoadCompatibilityList(); game_list->PopulateAsync(UISettings::values.game_dirs); + // Set up game list mode checkboxes. + SetGameListMode(UISettings::values.game_list_mode.GetValue()); + // make sure menubar has the arrow cursor instead of inheriting from this ui->menubar->setCursor(QCursor()); statusBar()->setCursor(QCursor()); @@ -1600,6 +1604,9 @@ void MainWindow::ConnectMenuEvents() { ui->action_Reset_Window_Size_900, ui->action_Reset_Window_Size_1080}); + connect_menu(ui->action_Grid_View, &MainWindow::SetGridView); + connect_menu(ui->action_Tree_View, &MainWindow::SetTreeView); + // Multiplayer connect(ui->action_View_Lobby, &QAction::triggered, multiplayer_state, &MultiplayerState::OnViewLobby); @@ -3373,6 +3380,22 @@ void MainWindow::ResetWindowSize1080() { ResetWindowSize(Layout::ScreenDocked::Width, Layout::ScreenDocked::Height); } +void MainWindow::SetGameListMode(Settings::GameListMode mode) { + ui->action_Grid_View->setChecked(mode == Settings::GameListMode::GridView); + ui->action_Tree_View->setChecked(mode == Settings::GameListMode::TreeView); + + UISettings::values.game_list_mode = mode; + game_list->ResetViewMode(); +} + +void MainWindow::SetGridView() { + SetGameListMode(Settings::GameListMode::GridView); +} + +void MainWindow::SetTreeView() { + SetGameListMode(Settings::GameListMode::TreeView); +} + void MainWindow::OnConfigure() { const auto old_theme = UISettings::values.theme; const bool old_discord_presence = UISettings::values.enable_discord_presence.GetValue(); diff --git a/src/yuzu/main_window.h b/src/yuzu/main_window.h index 41c2f4fea0..3261ccc9a1 100644 --- a/src/yuzu/main_window.h +++ b/src/yuzu/main_window.h @@ -17,6 +17,7 @@ #include #include "common/common_types.h" +#include "common/settings_enums.h" #include "frontend_common/content_manager.h" #include "frontend_common/update_checker.h" #include "input_common/drivers/tas_input.h" @@ -401,6 +402,11 @@ private slots: void ResetWindowSize720(); void ResetWindowSize900(); void ResetWindowSize1080(); + + void SetGameListMode(Settings::GameListMode mode); + void SetGridView(); + void SetTreeView(); + void LaunchFirmwareApplet(u64 program_id, std::optional mode); void OnCreateHomeMenuDesktopShortcut(); void OnCreateHomeMenuApplicationMenuShortcut(); diff --git a/src/yuzu/multiplayer/chat_room.cpp b/src/yuzu/multiplayer/chat_room.cpp index 53beda0f8e..a4d343f3a3 100644 --- a/src/yuzu/multiplayer/chat_room.cpp +++ b/src/yuzu/multiplayer/chat_room.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -20,7 +20,7 @@ #include "common/logging/log.h" #include "network/announce_multiplayer_session.h" #include "ui_chat_room.h" -#include "yuzu/game_list_p.h" +#include "yuzu/game/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 4e995c044f..2d6bcd3bad 100644 --- a/src/yuzu/multiplayer/client_room.cpp +++ b/src/yuzu/multiplayer/client_room.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -14,7 +14,7 @@ #include "common/logging/log.h" #include "network/announce_multiplayer_session.h" #include "ui_client_room.h" -#include "yuzu/game_list_p.h" +#include "yuzu/game/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 8b811d8878..3e5e42e442 100644 --- a/src/yuzu/multiplayer/host_room.cpp +++ b/src/yuzu/multiplayer/host_room.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project @@ -19,7 +19,7 @@ #include "core/internal_network/network_interface.h" #include "network/announce_multiplayer_session.h" #include "ui_host_room.h" -#include "yuzu/game_list_p.h" +#include "yuzu/game/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 fab9a56b2c..f28374f75f 100644 --- a/src/yuzu/multiplayer/lobby.cpp +++ b/src/yuzu/multiplayer/lobby.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project @@ -14,7 +14,7 @@ #include "core/internal_network/network_interface.h" #include "network/network.h" #include "ui_lobby.h" -#include "yuzu/game_list_p.h" +#include "yuzu/game/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/multiplayer/state.cpp b/src/yuzu/multiplayer/state.cpp index 7549194848..c344bcb8a3 100644 --- a/src/yuzu/multiplayer/state.cpp +++ b/src/yuzu/multiplayer/state.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2018 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -11,7 +11,7 @@ #include "common/announce_multiplayer_room.h" #include "common/logging/log.h" #include "core/core.h" -#include "yuzu/game_list.h" +#include "yuzu/game/game_list.h" #include "yuzu/multiplayer/client_room.h" #include "yuzu/multiplayer/direct_connect.h" #include "yuzu/multiplayer/host_room.h"