diff --git a/src/citron/game_list.cpp b/src/citron/game_list.cpp index ef23c05c0..e42d85294 100644 --- a/src/citron/game_list.cpp +++ b/src/citron/game_list.cpp @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2015 Citra Emulator Project +// SPDX-FileCopyrightText: 2025 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include @@ -16,6 +17,7 @@ #include #include "common/common_types.h" #include "common/logging/log.h" +#include "common/string_util.h" #include "core/core.h" #include "core/file_sys/patch_manager.h" #include "core/file_sys/registered_cache.h" @@ -187,12 +189,99 @@ void GameList::OnItemExpanded(const QModelIndex& item) { // 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(); + + if (list_view->isVisible()) { + // Grid view filtering - simpler approach + FilterGridView(edit_filter_text); + } else { + // Tree view filtering - existing logic + FilterTreeView(edit_filter_text); + } +} + +void GameList::FilterGridView(const QString& filter_text) { + // Repopulate the grid view with filtered items + QStandardItemModel* hierarchical_model = item_model; + + // Create a new flat model for grid view + QStandardItemModel* flat_model = new QStandardItemModel(this); + + int visible_count = 0; + int total_count = 0; + + // Collect all games from the hierarchical model + for (int i = 0; i < hierarchical_model->rowCount(); ++i) { + QStandardItem* folder = hierarchical_model->item(i, 0); + if (!folder) continue; + + // Skip non-game folders in grid view, but include favorites + const auto folder_type = folder->data(GameListItem::TypeRole).value(); + if (folder_type == GameListItemType::AddDir) { + continue; + } + + // Add games from this folder to the flat model + for (int j = 0; j < folder->rowCount(); ++j) { + QStandardItem* game_item = folder->child(j, 0); + if (!game_item) continue; + + const auto game_type = game_item->data(GameListItem::TypeRole).value(); + if (game_type == GameListItemType::Game) { + total_count++; + + bool should_show = true; + + if (!filter_text.isEmpty()) { + const QString file_path = game_item->data(GameListItemPath::FullPathRole).toString().toLower(); + const QString file_title = game_item->data(GameListItemPath::TitleRole).toString().toLower(); + const auto program_id = game_item->data(GameListItemPath::ProgramIdRole).toULongLong(); + 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; + + should_show = ContainsAllWords(file_name, filter_text) || + (file_program_id.count() == 16 && file_program_id.contains(filter_text)); + } + + if (should_show) { + // Clone the game item for the flat model + QStandardItem* cloned_item = game_item->clone(); + + // Set display text to just the game title for grid view + QString game_title = game_item->data(GameListItemPath::TitleRole).toString(); + if (game_title.isEmpty()) { + // Fallback to filename if no title + std::string filename; + Common::SplitPath(game_item->data(GameListItemPath::FullPathRole).toString().toStdString(), + nullptr, &filename, nullptr); + game_title = QString::fromStdString(filename); + } + cloned_item->setText(game_title); + + flat_model->appendRow(cloned_item); + visible_count++; + } + } + } + } + + // Set the flat model for the list view + list_view->setModel(flat_model); + + // Update grid size based on icon size + const u32 icon_size = UISettings::values.game_icon_size.GetValue(); + list_view->setGridSize(QSize(icon_size + 60, icon_size + 80)); // More padding for round icons and text + + search_field->setFilterResult(visible_count, total_count); +} + +void GameList::FilterTreeView(const QString& filter_text) { QStandardItem* folder; int children_total = 0; // If the searchfield is empty every item is visible // Otherwise the filter gets applied - if (edit_filter_text.isEmpty()) { + if (filter_text.isEmpty()) { tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), UISettings::values.favorited_ids.size() == 0); for (int i = 1; i < item_model->rowCount() - 1; ++i) { @@ -234,8 +323,8 @@ void GameList::OnTextChanged(const QString& new_text) { 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.count() == 16 && file_program_id.contains(edit_filter_text))) { + if (ContainsAllWords(file_name, filter_text) || + (file_program_id.count() == 16 && file_program_id.contains(filter_text))) { tree_view->setRowHidden(j, folder_index, false); ++result_count; } else { @@ -322,11 +411,14 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid this->main_window = parent; layout = new QVBoxLayout; tree_view = new QTreeView; + list_view = new QListView; 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); + // Configure tree view tree_view->setAlternatingRowColors(true); tree_view->setSelectionMode(QHeaderView::SingleSelection); tree_view->setSelectionBehavior(QHeaderView::SelectRows); @@ -337,6 +429,23 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid tree_view->setContextMenuPolicy(Qt::CustomContextMenu); tree_view->setStyleSheet(QStringLiteral("QTreeView{ border: none; }")); + // Configure list view for grid display + list_view->setViewMode(QListView::IconMode); + list_view->setResizeMode(QListView::Adjust); + list_view->setUniformItemSizes(true); + 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->setStyleSheet(QStringLiteral("QListView{ border: none; background: transparent; } QListView::item { text-align: center; padding: 5px; }")); + list_view->setGridSize(QSize(140, 160)); // Initial size for round icons and text + 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(); @@ -350,6 +459,8 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid connect(tree_view, &QTreeView::customContextMenuRequested, this, &GameList::PopupContextMenu); connect(tree_view, &QTreeView::expanded, this, &GameList::OnItemExpanded); connect(tree_view, &QTreeView::collapsed, this, &GameList::OnItemExpanded); + connect(list_view, &QListView::activated, this, &GameList::ValidateEntry); + connect(list_view, &QListView::customContextMenuRequested, this, &GameList::PopupContextMenu); connect(controller_navigation, &ControllerNavigation::TriggerKeyboardEvent, [this](Qt::Key key) { // Avoid pressing buttons while playing @@ -361,6 +472,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 @@ -370,8 +482,12 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); layout->addWidget(tree_view); + layout->addWidget(list_view); layout->addWidget(search_field); setLayout(layout); + + // Set initial view mode + SetViewMode(UISettings::values.game_list_grid_view.GetValue()); } void GameList::UnloadController() { @@ -505,11 +621,22 @@ void GameList::DonePopulating(const QStringList& watch_list) { item_model->sort(tree_view->header()->sortIndicatorSection(), tree_view->header()->sortIndicatorOrder()); + // Update grid view if it's currently active + if (list_view->isVisible()) { + PopulateGridView(); + } + emit PopulatingCompleted(); } void GameList::PopupContextMenu(const QPoint& menu_location) { - QModelIndex item = tree_view->indexAt(menu_location); + QModelIndex item; + if (tree_view->isVisible()) { + item = tree_view->indexAt(menu_location); + } else { + item = list_view->indexAt(menu_location); + } + if (!item.isValid()) return; @@ -535,7 +662,12 @@ void GameList::PopupContextMenu(const QPoint& menu_location) { default: break; } - context_menu.exec(tree_view->viewport()->mapToGlobal(menu_location)); + + if (tree_view->isVisible()) { + context_menu.exec(tree_view->viewport()->mapToGlobal(menu_location)); + } else { + context_menu.exec(list_view->viewport()->mapToGlobal(menu_location)); + } } void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path) { @@ -847,6 +979,7 @@ void GameList::PopulateAsync(QVector& game_dirs) { void GameList::SaveInterfaceLayout() { UISettings::values.gamelist_header_state = tree_view->header()->saveState(); + UISettings::values.game_list_grid_view.SetValue(list_view->isVisible()); } void GameList::LoadInterfaceLayout() { @@ -887,6 +1020,12 @@ void GameList::ToggleFavorite(u64 program_id) { tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true); } } + + // Update grid view if it's currently active + if (list_view->isVisible()) { + PopulateGridView(); + } + SaveConfig(); } @@ -968,3 +1107,76 @@ void GameListPlaceholder::changeEvent(QEvent* event) { void GameListPlaceholder::RetranslateUI() { text->setText(tr("Double-click to add a new folder to the game list")); } + +void GameList::SetViewMode(bool grid_view) { + if (grid_view) { + // Create a flat model for grid view showing only games + PopulateGridView(); + tree_view->setVisible(false); + list_view->setVisible(true); + list_view->setCurrentIndex(list_view->model()->index(0, 0)); + } else { + // Restore the hierarchical model for tree view + list_view->setVisible(false); + tree_view->setVisible(true); + tree_view->setCurrentIndex(item_model->index(0, 0)); + } +} + +void GameList::PopulateGridView() { + // Store the current hierarchical model + QStandardItemModel* hierarchical_model = item_model; + + // Create a new flat model for grid view + QStandardItemModel* flat_model = new QStandardItemModel(this); + + // Collect all games from the hierarchical model + for (int i = 0; i < hierarchical_model->rowCount(); ++i) { + QStandardItem* folder = hierarchical_model->item(i, 0); + if (!folder) continue; + + // Skip non-game folders in grid view, but include favorites + const auto folder_type = folder->data(GameListItem::TypeRole).value(); + if (folder_type == GameListItemType::AddDir) { + continue; + } + + // Add games from this folder to the flat model + for (int j = 0; j < folder->rowCount(); ++j) { + QStandardItem* game_item = folder->child(j, 0); + if (!game_item) continue; + + const auto game_type = game_item->data(GameListItem::TypeRole).value(); + if (game_type == GameListItemType::Game) { + // Clone the game item for the flat model + QStandardItem* cloned_item = game_item->clone(); + + // Set display text to just the game title for grid view + QString game_title = game_item->data(GameListItemPath::TitleRole).toString(); + if (game_title.isEmpty()) { + // Fallback to filename if no title + std::string filename; + Common::SplitPath(game_item->data(GameListItemPath::FullPathRole).toString().toStdString(), + nullptr, &filename, nullptr); + game_title = QString::fromStdString(filename); + } + cloned_item->setText(game_title); + + flat_model->appendRow(cloned_item); + } + } + } + + // Set the flat model for the list view + list_view->setModel(flat_model); + + // Update grid size based on icon size + const u32 icon_size = UISettings::values.game_icon_size.GetValue(); + list_view->setGridSize(QSize(icon_size + 60, icon_size + 80)); // More padding for round icons and text +} + +void GameList::ToggleViewMode() { + bool current_grid_view = UISettings::values.game_list_grid_view.GetValue(); + UISettings::values.game_list_grid_view.SetValue(!current_grid_view); + SetViewMode(!current_grid_view); +} diff --git a/src/citron/game_list.h b/src/citron/game_list.h index 36fb6972e..f78937d83 100644 --- a/src/citron/game_list.h +++ b/src/citron/game_list.h @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2015 Citra Emulator Project +// SPDX-FileCopyrightText: 2025 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #pragma once @@ -7,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -98,6 +100,9 @@ public: void SaveInterfaceLayout(); void LoadInterfaceLayout(); + void SetViewMode(bool grid_view); + void ToggleViewMode(); + QStandardItemModel* GetModel() const; /// Disables events from the emulated controller @@ -152,6 +157,11 @@ private: void AddFavorite(u64 program_id); void RemoveFavorite(u64 program_id); + void PopulateGridView(); + + void FilterGridView(const QString& filter_text); + void FilterTreeView(const QString& filter_text); + void PopupContextMenu(const QPoint& menu_location); void AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path); void AddCustomDirPopup(QMenu& context_menu, QModelIndex selected); @@ -167,6 +177,7 @@ private: GMainWindow* main_window = nullptr; QVBoxLayout* layout = nullptr; QTreeView* tree_view = nullptr; + QListView* list_view = nullptr; QStandardItemModel* item_model = nullptr; std::unique_ptr current_worker; QFileSystemWatcher* watcher = nullptr; diff --git a/src/citron/game_list_p.h b/src/citron/game_list_p.h index 2f0bc9880..bc8ef26a5 100644 --- a/src/citron/game_list_p.h +++ b/src/citron/game_list_p.h @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2015 Citra Emulator Project +// SPDX-FileCopyrightText: 2025 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #pragma once @@ -11,6 +12,8 @@ #include #include #include +#include +#include #include #include #include @@ -45,6 +48,31 @@ static QPixmap GetDefaultIcon(u32 size) { return icon; } +/** + * Creates a round icon from a square pixmap + * @param pixmap The source pixmap + * @param size The desired size + * @return QPixmap round icon + */ +static QPixmap CreateRoundIcon(const QPixmap& pixmap, u32 size) { + QPixmap rounded(size, size); + rounded.fill(Qt::transparent); + + QPainter painter(&rounded); + painter.setRenderHint(QPainter::Antialiasing); + + // Create a circular clipping path + QPainterPath path; + path.addEllipse(0, 0, size, size); + painter.setClipPath(path); + + // Draw the scaled pixmap + QPixmap scaled = pixmap.scaled(size, size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + painter.drawPixmap(0, 0, scaled); + + return rounded; +} + class GameListItem : public QStandardItem { public: @@ -85,9 +113,11 @@ public: if (!picture.loadFromData(picture_data.data(), static_cast(picture_data.size()))) { picture = GetDefaultIcon(size); } - picture = picture.scaled(size, size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - setData(picture, Qt::DecorationRole); + // Create a round icon + QPixmap round_picture = CreateRoundIcon(picture, size); + + setData(round_picture, Qt::DecorationRole); } int type() const override { diff --git a/src/citron/main.cpp b/src/citron/main.cpp index 6e4427518..edd57e64a 100644 --- a/src/citron/main.cpp +++ b/src/citron/main.cpp @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-FileCopyrightText: 2025 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include @@ -1344,6 +1345,7 @@ void GMainWindow::InitializeHotkeys() { LinkActionShortcut(ui->action_Pause, QStringLiteral("Continue/Pause Emulation")); LinkActionShortcut(ui->action_Stop, QStringLiteral("Stop Emulation")); LinkActionShortcut(ui->action_Show_Filter_Bar, QStringLiteral("Toggle Filter Bar")); + LinkActionShortcut(ui->action_Toggle_Grid_View, QStringLiteral("Toggle Grid View")); LinkActionShortcut(ui->action_Show_Status_Bar, QStringLiteral("Toggle Status Bar")); LinkActionShortcut(ui->action_Fullscreen, QStringLiteral("Fullscreen")); LinkActionShortcut(ui->action_Capture_Screenshot, QStringLiteral("Capture Screenshot")); @@ -1441,6 +1443,8 @@ void GMainWindow::RestoreUIState() { ui->action_Show_Filter_Bar->setChecked(UISettings::values.show_filter_bar.GetValue()); game_list->SetFilterVisible(ui->action_Show_Filter_Bar->isChecked()); + ui->action_Toggle_Grid_View->setChecked(UISettings::values.game_list_grid_view.GetValue()); + ui->action_Show_Status_Bar->setChecked(UISettings::values.show_status_bar.GetValue()); statusBar()->setVisible(ui->action_Show_Status_Bar->isChecked()); Debugger::ToggleConsole(); @@ -1558,6 +1562,7 @@ void GMainWindow::ConnectMenuEvents() { connect_menu(ui->action_Display_Dock_Widget_Headers, &GMainWindow::OnDisplayTitleBars); connect_menu(ui->action_Show_Filter_Bar, &GMainWindow::OnToggleFilterBar); connect_menu(ui->action_Show_Status_Bar, &GMainWindow::OnToggleStatusBar); + connect_menu(ui->action_Toggle_Grid_View, &GMainWindow::OnToggleGridView); connect_menu(ui->action_Reset_Window_Size_720, &GMainWindow::ResetWindowSize720); connect_menu(ui->action_Reset_Window_Size_900, &GMainWindow::ResetWindowSize900); @@ -5341,3 +5346,7 @@ int main(int argc, char* argv[]) { detached_tasks.WaitForAllTasks(); return result; } + +void GMainWindow::OnToggleGridView() { + game_list->ToggleViewMode(); +} diff --git a/src/citron/main.h b/src/citron/main.h index 4822a8981..e6b3212fc 100644 --- a/src/citron/main.h +++ b/src/citron/main.h @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2014 Citra Emulator Project +// SPDX-FileCopyrightText: 2025 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #pragma once @@ -383,6 +384,7 @@ private slots: void OnInstallDecryptionKeys(); void OnAbout(); void OnToggleFilterBar(); + void OnToggleGridView(); void OnToggleStatusBar(); void OnDisplayTitleBars(bool); void InitializeHotkeys(); diff --git a/src/citron/main.ui b/src/citron/main.ui index f356c8bba..67d9c79ed 100644 --- a/src/citron/main.ui +++ b/src/citron/main.ui @@ -124,6 +124,7 @@ + @@ -287,6 +288,17 @@ Show Status Bar + + + true + + + &Grid View + + + Grid View + + true diff --git a/src/citron/uisettings.h b/src/citron/uisettings.h index f74785668..6b6b42212 100644 --- a/src/citron/uisettings.h +++ b/src/citron/uisettings.h @@ -197,6 +197,7 @@ 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_grid_view{linkage, false, "game_list_grid_view", 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}; @@ -229,7 +230,7 @@ void RestoreWindowState(std::unique_ptr& qtConfig); // This must be in alphabetical order according to action name as it must have the same order as // UISetting::values.shortcuts, which is alphabetically ordered. // clang-format off -const std::array default_hotkeys{{ +const std::array default_hotkeys{{ {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Mute/Unmute")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+M"), std::string("Home+Dpad_Right"), Qt::WindowShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Volume Down")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("-"), std::string("Home+Dpad_Down"), Qt::ApplicationShortcut, true}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Volume Up")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("="), std::string("Home+Dpad_Up"), Qt::ApplicationShortcut, true}}, @@ -254,6 +255,7 @@ const std::array default_hotkeys{{ {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "TAS Reset")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+F6"), std::string(""), Qt::ApplicationShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "TAS Start/Stop")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+F5"), std::string(""), Qt::ApplicationShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Filter Bar")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+F"), std::string(""), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Grid View")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+G"), std::string(""), Qt::WindowShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Framerate Limit")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+U"), std::string("Home+Y"), Qt::ApplicationShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Mouse Panning")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+F9"), std::string(""), Qt::ApplicationShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Renderdoc Capture")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string(""), std::string(""), Qt::ApplicationShortcut, false}},