From b011ba063cce0c0664d9d17dc883eead3e2827f1 Mon Sep 17 00:00:00 2001 From: SternXD Date: Thu, 24 Sep 2026 20:39:28 -0400 Subject: [PATCH] GameList: Add favorites to game list Signed-off-by: SternXD --- bin/resources/icons/star.svg | 1 + pcsx2-gsrunner/Main.cpp | 4 ++ pcsx2-qt/GameList/GameListModel.cpp | 24 +++++++++ pcsx2-qt/GameList/GameListModel.h | 3 ++ pcsx2-qt/GameList/GameListWidget.cpp | 69 +++++++++++++++++++++++- pcsx2-qt/MainWindow.cpp | 14 +++++ pcsx2-qt/MainWindow.h | 3 ++ pcsx2-qt/QtHost.cpp | 5 ++ pcsx2/GameList.cpp | 38 +++++++++++-- pcsx2/GameList.h | 2 + pcsx2/Host.h | 3 ++ pcsx2/ImGui/FullscreenUI.cpp | 81 +++++++++++++++++++++++----- pcsx2/ImGui/FullscreenUI.h | 1 + pcsx2/ImGui/FullscreenUI_Internal.h | 1 + tests/ctest/core/StubHost.cpp | 4 ++ 15 files changed, 237 insertions(+), 16 deletions(-) create mode 100644 bin/resources/icons/star.svg diff --git a/bin/resources/icons/star.svg b/bin/resources/icons/star.svg new file mode 100644 index 0000000000..f4eea4b555 --- /dev/null +++ b/bin/resources/icons/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pcsx2-gsrunner/Main.cpp b/pcsx2-gsrunner/Main.cpp index 3e10aece98..03879ca6ab 100644 --- a/pcsx2-gsrunner/Main.cpp +++ b/pcsx2-gsrunner/Main.cpp @@ -305,6 +305,10 @@ void Host::CancelGameListRefresh() { } +void Host::OnGameListFavoriteChanged(const std::string& path) +{ +} + bool Host::IsFullscreen() { return false; diff --git a/pcsx2-qt/GameList/GameListModel.cpp b/pcsx2-qt/GameList/GameListModel.cpp index 6236390e30..04f383566a 100644 --- a/pcsx2-qt/GameList/GameListModel.cpp +++ b/pcsx2-qt/GameList/GameListModel.cpp @@ -242,6 +242,8 @@ QVariant GameListModel::data(const QModelIndex& index, const int role) const const GameList::Entry* ge = GameList::GetEntryByIndex(row); if (!ge) return QVariant(); + if (role == FavoriteRole) + return ge->is_favorite; // See: https://doc.qt.io/qt-6/qt.html#ItemDataRole-enum switch (role) @@ -283,6 +285,11 @@ QVariant GameListModel::data(const QModelIndex& index, const int role) const { switch (index.column()) { + case Column_Title: + if (ge->is_favorite) + return m_favorite_pixmap; + return QVariant(); + case Column_Type: return m_type_pixmaps[static_cast(ge->type)]; @@ -368,6 +375,21 @@ void GameListModel::refresh() endResetModel(); } +void GameListModel::refreshFavorite(const std::string& path) +{ + const auto lock = GameList::GetLock(); + const u32 count = GameList::GetEntryCount(); + for (u32 row = 0; row < count; row++) + { + const GameList::Entry* entry = GameList::GetEntryByIndex(row); + if (entry && entry->path == path) + { + emit dataChanged(index(static_cast(row), 0), index(static_cast(row), Column_Count - 1)); + break; + } + } +} + bool GameListModel::titlesLessThan(const int left_row, const int right_row) const { if (left_row < 0 || left_row >= static_cast(GameList::GetEntryCount()) || right_row < 0 || @@ -525,6 +547,8 @@ void GameListModel::loadCommonImages() loadThemeSpecificImages(); const QString base_path(QtHost::GetResourcesBasePath()); + m_favorite_pixmap = QIcon(QStringLiteral("%1/icons/star.svg").arg(base_path)).pixmap(QSize(20, 20), m_dpr); + for (u32 rating = 1; rating < GameList::CompatibilityRatingCount; rating++) m_compatibility_pixmaps[rating] = QIcon((QStringLiteral("%1/icons/star-%2.svg").arg(base_path).arg(rating - 1))).pixmap(QSize(88, 16), m_dpr); diff --git a/pcsx2-qt/GameList/GameListModel.h b/pcsx2-qt/GameList/GameListModel.h index a74092d153..362db91295 100644 --- a/pcsx2-qt/GameList/GameListModel.h +++ b/pcsx2-qt/GameList/GameListModel.h @@ -20,6 +20,7 @@ class GameListModel final : public QAbstractTableModel Q_OBJECT public: + static constexpr int FavoriteRole = Qt::UserRole + 1; enum Column : int { Column_Type, @@ -54,6 +55,7 @@ public: __fi const QString& getColumnDisplayName(int column) { return m_column_display_names[column]; } void refresh(); + void refreshFavorite(const std::string& path); void reloadThemeSpecificImages(); bool titlesLessThan(int left_row, int right_row) const; @@ -100,6 +102,7 @@ private: std::array(GameList::Region::Count)> m_region_pixmaps; QPixmap m_placeholder_pixmap; QPixmap m_loading_pixmap; + QPixmap m_favorite_pixmap; qreal m_dpr; std::array(GameList::CompatibilityRatingCount)> m_compatibility_pixmaps; diff --git a/pcsx2-qt/GameList/GameListWidget.cpp b/pcsx2-qt/GameList/GameListWidget.cpp index a8b931e41b..2ffa2996bf 100644 --- a/pcsx2-qt/GameList/GameListWidget.cpp +++ b/pcsx2-qt/GameList/GameListWidget.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include static const char* SUPPORTED_FORMATS_STRING = QT_TRANSLATE_NOOP(GameListWidget, @@ -94,15 +95,26 @@ public: m_filter_name = name; endFilterChange(Direction::Rows); } + void setFilterFavoritesOnly(bool enabled) + { + beginFilterChange(); + m_filter_favorites_only = enabled; + endFilterChange(Direction::Rows); + } bool filterAcceptsRow(int source_row, const QModelIndex& source_parent) const override { if (m_filter_type != GameList::EntryType::Count || m_filter_region != GameList::Region::Count || - !m_filter_name.isEmpty()) + !m_filter_name.isEmpty() || + m_filter_favorites_only) { const auto lock = GameList::GetLock(); const GameList::Entry* entry = GameList::GetEntryByIndex(source_row); + if (!entry) + return false; + if (m_filter_favorites_only && !entry->is_favorite) + return false; if (m_filter_type != GameList::EntryType::Count && entry->type != m_filter_type) return false; if (m_filter_region != GameList::Region::Count && entry->region != m_filter_region) @@ -120,6 +132,11 @@ public: bool lessThan(const QModelIndex& source_left, const QModelIndex& source_right) const override { + const bool left_favorite = source_left.data(GameListModel::FavoriteRole).toBool(); + const bool right_favorite = source_right.data(GameListModel::FavoriteRole).toBool(); + if (left_favorite != right_favorite) + return (sortOrder() == Qt::AscendingOrder) ? left_favorite : !left_favorite; + return m_model->lessThan(source_left, source_right, source_left.column()); } @@ -128,6 +145,7 @@ private: GameList::EntryType m_filter_type = GameList::EntryType::Count; GameList::Region m_filter_region = GameList::Region::Count; QString m_filter_name; + bool m_filter_favorites_only = false; }; namespace @@ -226,6 +244,38 @@ namespace painter->restore(); } }; + + class FavoriteCoverDelegate final : public QStyledItemDelegate + { + public: + using QStyledItemDelegate::QStyledItemDelegate; + + void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override + { + QStyledItemDelegate::paint(painter, option, index); + if (!index.data(GameListModel::FavoriteRole).toBool()) + return; + + QStyleOptionViewItem view_option(option); + initStyleOption(&view_option, index); + const QStyle* style = option.widget ? option.widget->style() : QApplication::style(); + const QRect decoration_rect = style->subElementRect(QStyle::SE_ItemViewItemDecoration, &view_option, option.widget); + const QPixmap cover = qvariant_cast(index.data(Qt::DecorationRole)); + if (cover.isNull() || decoration_rect.isEmpty()) + return; + + const QRect cover_rect = QStyle::alignedRect(option.direction, option.decorationAlignment, + cover.deviceIndependentSize().toSize(), decoration_rect); + const int star_size = std::clamp(qRound(static_cast(cover_rect.width()) * 0.12), 9, 42); + const int margin = std::max(3, star_size / 3); + painter->save(); + const QRect star_rect(cover_rect.right() - star_size - margin, cover_rect.top() + margin, star_size, star_size); + static const QIcon star_icon(QStringLiteral("%1/icons/star.svg").arg(QtHost::GetResourcesBasePath())); + const QPixmap star_pixmap = star_icon.pixmap(QSize(star_size, star_size), option.widget ? option.widget->devicePixelRatio() : 1.0); + painter->drawPixmap(star_rect.topLeft(), star_pixmap); + painter->restore(); + } + }; } // namespace GameListWidget::GameListWidget(QWidget* parent /* = nullptr */) @@ -280,6 +330,22 @@ void GameListWidget::initialize() m_sort_model->setFilterName(text); }); + const bool favorites_only = Host::GetBaseBoolSettingValue("GameList", "FavoritesOnly", false); + QToolButton* filter_favorites = new QToolButton(this); + filter_favorites->setCheckable(true); + filter_favorites->setAutoRaise(true); + filter_favorites->setToolTip(tr("Show Favorites Only")); + filter_favorites->setIcon(QIcon(QStringLiteral("%1/icons/star.svg").arg(QtHost::GetResourcesBasePath()))); + filter_favorites->setChecked(favorites_only); + m_sort_model->setFilterFavoritesOnly(favorites_only); + m_ui.horizontalLayout_2->insertWidget(0, filter_favorites); + + connect(filter_favorites, &QToolButton::toggled, this, [this](bool checked) { + m_sort_model->setFilterFavoritesOnly(checked); + Host::SetBaseBoolSettingValue("GameList", "FavoritesOnly", checked); + Host::CommitBaseSettingChanges(); + }); + connect(new QShortcut(QKeySequence::Find, this), &QShortcut::activated, [this]() { m_ui.searchText->setFocus(); }); @@ -343,6 +409,7 @@ void GameListWidget::initialize() m_list_view = new GameListGridListView(m_ui.stack); m_list_view->setModel(m_sort_model); m_list_view->setModelColumn(GameListModel::Column_Cover); + m_list_view->setItemDelegate(new FavoriteCoverDelegate(m_list_view)); m_list_view->setSelectionMode(QAbstractItemView::SingleSelection); m_list_view->setViewMode(QListView::IconMode); m_list_view->setResizeMode(QListView::Adjust); diff --git a/pcsx2-qt/MainWindow.cpp b/pcsx2-qt/MainWindow.cpp index 54c7744746..c8bf642a7f 100644 --- a/pcsx2-qt/MainWindow.cpp +++ b/pcsx2-qt/MainWindow.cpp @@ -5,6 +5,7 @@ #include "AutoUpdaterDialog.h" #include "CoverDownloadDialog.h" #include "DisplayWidget.h" +#include "GameList/GameListModel.h" #include "GameList/GameListRefreshThread.h" #include "GameList/GameListWidget.h" #include "LogWindow.h" @@ -1542,6 +1543,13 @@ void MainWindow::refreshGameList(bool invalidate_cache, bool popup_on_error) m_game_list_widget->refresh(invalidate_cache, popup_on_error); } +void MainWindow::refreshGameListFavorite(const std::string& path) +{ + m_game_list_widget->getModel()->refreshFavorite(path); + if (MTGS::IsOpen()) + Host::RunOnGSThread(&FullscreenUI::GameListFavoriteChanged); +} + void MainWindow::cancelGameListRefresh() { m_game_list_widget->cancelRefresh(); @@ -1855,6 +1863,7 @@ void MainWindow::onGameListEntryContextMenuRequested(const QPoint& point) if (entry.has_value()) { + const bool is_favorite = entry->is_favorite; QAction* action = menu.addAction(tr("Properties...")); action->setEnabled(!entry->serial.empty() || entry->type == GameList::EntryType::ELF); if (action->isEnabled()) @@ -1881,6 +1890,11 @@ void MainWindow::onGameListEntryContextMenuRequested(const QPoint& point) connect(menu.addAction(tr("Exclude From List")), &QAction::triggered, [this, entry]() { getSettingsWindow()->getGameListSettingsWidget()->addExcludedPath(entry->path); }); + action = menu.addAction(is_favorite ? tr("Remove from Favorites") : tr("Add to Favorites")); + connect(action, &QAction::triggered, this, [entry, is_favorite]() { + GameList::SaveFavoriteForPath(entry->path, !is_favorite); + }); + const time_t entry_played_time = GameList::GetCachedPlayedTimeForSerial(entry->serial); // Best two options given zero play time are to grey this out or to not show it at all. if (entry_played_time) diff --git a/pcsx2-qt/MainWindow.h b/pcsx2-qt/MainWindow.h index b909ba815e..5be9942055 100644 --- a/pcsx2-qt/MainWindow.h +++ b/pcsx2-qt/MainWindow.h @@ -113,6 +113,9 @@ public: /// Rescans a single file. NOTE: Happens on UI thread. void rescanFile(const std::string& path); + /// Updates a favorite in the game list. NOTE: Happens on UI thread. + void refreshGameListFavorite(const std::string& path); + /// Start a file from a user action (e.g. dragging a file onto the main window or with macOS open with) bool startFile(const QString& path); diff --git a/pcsx2-qt/QtHost.cpp b/pcsx2-qt/QtHost.cpp index a177f3980f..5c28f9154b 100644 --- a/pcsx2-qt/QtHost.cpp +++ b/pcsx2-qt/QtHost.cpp @@ -1268,6 +1268,11 @@ void Host::CancelGameListRefresh() QMetaObject::invokeMethod(g_main_window, "cancelGameListRefresh", Qt::BlockingQueuedConnection); } +void Host::OnGameListFavoriteChanged(const std::string& path) +{ + QtHost::RunOnUIThread([path]() { g_main_window->refreshGameListFavorite(path); }); +} + void Host::RequestExitApplication(bool allow_confirm) { QMetaObject::invokeMethod(g_main_window, "requestExit", Qt::QueuedConnection, Q_ARG(bool, allow_confirm)); diff --git a/pcsx2/GameList.cpp b/pcsx2/GameList.cpp index 5d92175020..2ead072b8c 100644 --- a/pcsx2/GameList.cpp +++ b/pcsx2/GameList.cpp @@ -65,7 +65,8 @@ namespace GameList static bool GetGameListEntryFromCache(const std::string& path, GameList::Entry* entry); static void ScanDirectory(const char* path, bool recursive, bool only_cache, const std::vector& excluded_paths, const PlayedTimeMap& played_time_map, const INISettingsInterface& custom_attributes_ini, ProgressCallback* progress); - static bool AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map); + static bool AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map, + const INISettingsInterface& custom_attributes_ini); static bool ScanFile(std::string path, std::time_t timestamp, std::unique_lock& lock, const PlayedTimeMap& played_time_map, const INISettingsInterface& custom_attributes_ini); @@ -716,7 +717,8 @@ void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache, } std::unique_lock lock(s_mutex); - if (GetEntryForPath(ffd.FileName.c_str()) || AddFileFromCache(ffd.FileName, ffd.ModificationTime, played_time_map) || only_cache) + if (GetEntryForPath(ffd.FileName.c_str()) || + AddFileFromCache(ffd.FileName, ffd.ModificationTime, played_time_map, custom_attributes_ini) || only_cache) { continue; } @@ -731,7 +733,8 @@ void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache, progress->PopState(); } -bool GameList::AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map) +bool GameList::AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map, + const INISettingsInterface& custom_attributes_ini) { Entry entry; if (!GetGameListEntryFromCache(path, &entry) || entry.last_modified_time != timestamp) @@ -747,6 +750,7 @@ bool GameList::AddFileFromCache(const std::string& path, std::time_t timestamp, entry.last_played_time = iter->second.last_played_time; entry.total_played_time = iter->second.total_played_time; } + entry.is_favorite = custom_attributes_ini.GetBoolValue(EncodeIniKey(entry.path).c_str(), "Favorite", false); s_entries.push_back(std::move(entry)); return true; @@ -800,6 +804,7 @@ bool GameList::ScanFile(std::string path, std::time_t timestamp, std::unique_loc entry.region = static_cast(custom_region_value); } } + entry.is_favorite = custom_attributes_ini.GetBoolValue(EncodeIniKey(entry.path).c_str(), "Favorite", false); lock.lock(); @@ -1525,6 +1530,33 @@ void GameList::SaveCustomRegionForPath(const std::string& path, int custom_regio } } +void GameList::SaveFavoriteForPath(const std::string& path, bool favorite) +{ + INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile()); + custom_attributes_ini.Load(); + const std::string key(EncodeIniKey(path)); + if (favorite) + custom_attributes_ini.SetBoolValue(key.c_str(), "Favorite", true); + else + custom_attributes_ini.DeleteValue(key.c_str(), "Favorite"); + + if (!custom_attributes_ini.Save()) + return; + + { + std::unique_lock lock(s_mutex); + for (Entry& entry : s_entries) + { + if (entry.path == path) + { + entry.is_favorite = favorite; + break; + } + } + } + Host::OnGameListFavoriteChanged(path); +} + std::string GameList::GetCustomTitleForPath(const std::string& path) { std::string ret; diff --git a/pcsx2/GameList.h b/pcsx2/GameList.h index 3a47e98420..910182cf2b 100644 --- a/pcsx2/GameList.h +++ b/pcsx2/GameList.h @@ -74,6 +74,7 @@ namespace GameList { EntryType type = EntryType::PS2Disc; Region region = Region::Other; + bool is_favorite = false; std::string path; std::string serial; @@ -160,5 +161,6 @@ namespace GameList void CheckCustomAttributesForPath(const std::string& path, bool& has_custom_title, bool& has_custom_region); void SaveCustomTitleForPath(const std::string& path, const std::string& custom_title); void SaveCustomRegionForPath(const std::string& path, int custom_region); + void SaveFavoriteForPath(const std::string& path, bool favorite); std::string GetCustomTitleForPath(const std::string& path); } // namespace GameList diff --git a/pcsx2/Host.h b/pcsx2/Host.h index a487ffed72..a233967583 100644 --- a/pcsx2/Host.h +++ b/pcsx2/Host.h @@ -96,6 +96,9 @@ namespace Host /// Cancels game list refresh, if there is one in progress. void CancelGameListRefresh(); + /// Notifies the host when a favorite changes. May be called from any thread. + void OnGameListFavoriteChanged(const std::string& path); + /// Requests shut down of the current virtual machine. void RequestVMShutdown(bool allow_confirm, bool allow_save_state, bool default_save_state); diff --git a/pcsx2/ImGui/FullscreenUI.cpp b/pcsx2/ImGui/FullscreenUI.cpp index cca1dbbc05..54b015da78 100644 --- a/pcsx2/ImGui/FullscreenUI.cpp +++ b/pcsx2/ImGui/FullscreenUI.cpp @@ -265,6 +265,11 @@ void FullscreenUI::PreferEnglishGameListChanged() s_prefer_english_titles = Host::GetBaseBoolSettingValue("UI", "PreferEnglishGameList", false); } +void FullscreenUI::GameListFavoriteChanged() +{ + s_game_list_needs_sort = true; +} + // When drawing an svg to a non-integer size, we get a padded texture. // This function crops off this padding by setting the image UV for the draw. // We currently only use integer sizes for images, but I wrote this before checking that. @@ -2478,13 +2483,16 @@ void FullscreenUI::PopulateGameListEntryList() { const int sort = Host::GetBaseIntSettingValue("UI", "FullscreenUIGameSort", 0); const bool reverse = Host::GetBaseBoolSettingValue("UI", "FullscreenUIGameSortReverse", false); + const bool favorites_only = Host::GetBaseBoolSettingValue("UI", "FullscreenUIFavoritesOnly", false); static int s_last_sort = -1; static bool s_last_reverse = false; static bool s_last_prefer_eng = false; + static bool s_last_favorites_only = false; // Sort can be expensive, try to avoid when possible const u32 count = GameList::GetEntryCount(); - bool needs_update = sort != s_last_sort || reverse != s_last_reverse || s_last_prefer_eng != s_prefer_english_titles; + bool needs_update = s_game_list_needs_sort || sort != s_last_sort || reverse != s_last_reverse || + s_last_prefer_eng != s_prefer_english_titles || favorites_only != s_last_favorites_only; needs_update |= count != s_last_unsorted_entries.size(); if (!needs_update) { @@ -2500,10 +2508,12 @@ void FullscreenUI::PopulateGameListEntryList() if (!needs_update) return; + s_game_list_needs_sort = false; s_last_sort = sort; s_last_reverse = reverse; s_last_prefer_eng = s_prefer_english_titles; + s_last_favorites_only = favorites_only; s_game_list_sorted_entries.resize(count); s_last_unsorted_entries.resize(count); @@ -2512,9 +2522,19 @@ void FullscreenUI::PopulateGameListEntryList() s_game_list_sorted_entries[i] = GameList::GetEntryByIndex(i); s_last_unsorted_entries[i] = s_game_list_sorted_entries[i]; } + if (favorites_only) + { + s_game_list_sorted_entries.erase( + std::remove_if(s_game_list_sorted_entries.begin(), s_game_list_sorted_entries.end(), + [](const GameList::Entry* entry) { return !entry->is_favorite; }), + s_game_list_sorted_entries.end()); + } std::sort(s_game_list_sorted_entries.begin(), s_game_list_sorted_entries.end(), [sort, reverse](const GameList::Entry* lhs, const GameList::Entry* rhs) { + if (lhs->is_favorite != rhs->is_favorite) + return lhs->is_favorite; + switch (sort) { case 0: // Type @@ -2768,8 +2788,23 @@ void FullscreenUI::DrawGameList(const ImVec2& heading_size) const std::string& title = entry->GetTitle(s_prefer_english_titles); ImGui::PushFont(g_large_font.first, g_large_font.second); - ImGui::RenderTextClipped(title_bb.Min, title_bb.Max, title.c_str(), title.c_str() + title.size(), nullptr, - ImVec2(0.0f, 0.0f), &title_bb); + const bool is_favorite = entry->is_favorite; + const float favorite_size = GetLineHeight(g_large_font) * 0.75f; + const float favorite_margin = LayoutScale(8.0f); + ImRect visible_title_bb(title_bb); + if (is_favorite) + visible_title_bb.Max.x -= favorite_size + (favorite_margin * 2.0f); + ImGui::RenderTextClipped(visible_title_bb.Min, visible_title_bb.Max, title.c_str(), title.c_str() + title.size(), nullptr, + ImVec2(0.0f, 0.0f), &visible_title_bb); + if (is_favorite) + { + const ImVec2 star_pos(title_bb.Max.x - favorite_size - favorite_margin, + title_bb.Min.y + (GetLineHeight(g_large_font) - favorite_size) * 0.5f); + ImGui::GetWindowDrawList()->AddText(g_large_font.first, favorite_size, + ImVec2(star_pos.x + LayoutScale(1.0f), star_pos.y + LayoutScale(1.0f)), IM_COL32(0, 0, 0, 200), ICON_PF_STAR); + ImGui::GetWindowDrawList()->AddText(g_large_font.first, favorite_size, + star_pos, IM_COL32(255, 196, 0, 255), ICON_PF_STAR); + } ImGui::PopFont(); if (!summary.empty()) @@ -3002,6 +3037,16 @@ void FullscreenUI::DrawGameGrid(const ImVec2& heading_size) bb.Max -= style.FramePadding; DrawGameCover(entry, ImGui::GetWindowDrawList(), bb.Min, bb.Min + image_size); + if (entry->is_favorite) + { + const float star_size = std::clamp(image_width * 0.12f, LayoutScale(18.0f), LayoutScale(42.0f)); + const ImVec2 star_pos(bb.Min.x + image_width - star_size - LayoutScale(6.0f), bb.Min.y + LayoutScale(6.0f)); + ImGui::GetWindowDrawList()->AddText(g_medium_font.first, star_size, + ImVec2(star_pos.x + LayoutScale(1.0f), star_pos.y + LayoutScale(1.0f)), + IM_COL32(0, 0, 0, 200), ICON_PF_STAR); + ImGui::GetWindowDrawList()->AddText(g_medium_font.first, star_size, star_pos, + IM_COL32(255, 196, 0, 255), ICON_PF_STAR); + } const bool show_titles = Host::GetBaseBoolSettingValue("UI", "FullscreenUIShowGameGridTitles", true); @@ -3045,11 +3090,13 @@ void FullscreenUI::HandleGameListActivate(const GameList::Entry* entry) void FullscreenUI::HandleGameListOptions(const GameList::Entry* entry) { + const bool is_favorite = entry->is_favorite; ImGuiFullscreen::ChoiceDialogOptions options = { {FSUI_ICONSTR(ICON_FA_WRENCH, "Game Properties"), false}, + {is_favorite ? FSUI_ICONSTR(ICON_PF_STAR, "Remove from Favorites") : FSUI_ICONSTR(ICON_PF_STAR, "Add to Favorites"), false}, {FSUI_ICONSTR(ICON_FA_PLAY, "Resume Game"), false}, {FSUI_ICONSTR(ICON_FA_ARROW_ROTATE_LEFT, "Load State"), false}, - {FSUI_ICONSTR(ICON_PF_STAR, "Default Boot"), false}, + {FSUI_ICONSTR(ICON_PF_START, "Default Boot"), false}, {FSUI_ICONSTR(ICON_FA_FORWARD_FAST, "Fast Boot"), false}, {FSUI_ICONSTR(ICON_FA_COMPACT_DISC, "Full Boot"), false}, }; @@ -3061,29 +3108,33 @@ void FullscreenUI::HandleGameListOptions(const GameList::Entry* entry) const bool has_resume_state = VMManager::HasSaveStateInSlot(entry->serial.c_str(), entry->crc, -1); OpenChoiceDialog(entry->GetTitle(s_prefer_english_titles).c_str(), false, std::move(options), - [has_resume_state, entry_path = entry->path, entry_serial = entry->serial, entry_title = entry->title, entry_played_time] - (s32 index, const std::string& title, bool checked) { + [has_resume_state, entry_path = entry->path, entry_serial = entry->serial, entry_title = entry->title, entry_played_time, + is_favorite](s32 index, const std::string& title, bool checked) { switch (index) { case 0: // Open Game Properties SwitchToGameSettings(entry_path); break; - case 1: // Resume Game + case 1: // Add/Remove from Favorites + GameList::SaveFavoriteForPath(entry_path, !is_favorite); + s_game_list_needs_sort = true; + break; + case 2: // Resume Game DoStartPath(entry_path, has_resume_state ? std::optional(-1) : std::optional()); break; - case 2: // Load State + case 3: // Load State OpenLoadStateSelectorForGame(entry_path); break; - case 3: // Default Boot + case 4: // Default Boot DoStartPath(entry_path); break; - case 4: // Fast Boot + case 5: // Fast Boot DoStartPath(entry_path, std::nullopt, true); break; - case 5: // Full Boot + case 6: // Full Boot DoStartPath(entry_path, std::nullopt, false); break; - case 6: + case 7: { // Close Menu if (!entry_played_time) @@ -3126,6 +3177,7 @@ void FullscreenUI::DrawGameListSettingsWindow() if (NavButton(ICON_PF_BACKWARD, true, true)) { s_current_main_window = MainWindowType::GameList; + s_game_list_needs_sort = true; QueueResetFocus(FocusResetType::WindowChanged); } @@ -3147,6 +3199,7 @@ void FullscreenUI::DrawGameListSettingsWindow() if (ImGui::IsWindowFocused() && WantsToCloseMenu()) { s_current_main_window = MainWindowType::GameList; + s_game_list_needs_sort = true; QueueResetFocus(FocusResetType::WindowChanged); } @@ -3265,6 +3318,9 @@ void FullscreenUI::DrawGameListSettingsWindow() DrawToggleSetting(bsi, FSUI_ICONSTR(ICON_FA_TAG, "Show Titles"), FSUI_CSTR("Shows Titles for Games when in Game Grid View Mode"), "UI", "FullscreenUIShowGameGridTitles", true); + DrawToggleSetting(bsi, FSUI_ICONSTR(ICON_PF_STAR, "Favorites Only"), + FSUI_CSTR("Only displays games marked as favorites in the list and grid."), "UI", + "FullscreenUIFavoritesOnly", false); } MenuHeading(FSUI_CSTR("Operations")); @@ -3291,6 +3347,7 @@ void FullscreenUI::DrawGameListSettingsWindow() void FullscreenUI::SwitchToGameList() { s_current_main_window = MainWindowType::GameList; + s_game_list_needs_sort = true; s_game_list_view = static_cast(Host::GetBaseIntSettingValue("UI", "DefaultFullscreenUIGameView", 0)); { auto lock = Host::GetSettingsLock(); diff --git a/pcsx2/ImGui/FullscreenUI.h b/pcsx2/ImGui/FullscreenUI.h index a15b584c7e..a3159cecb3 100644 --- a/pcsx2/ImGui/FullscreenUI.h +++ b/pcsx2/ImGui/FullscreenUI.h @@ -40,6 +40,7 @@ namespace FullscreenUI void LocaleChanged(); void GamepadLayoutChanged(); void PreferEnglishGameListChanged(); + void GameListFavoriteChanged(); void Shutdown(bool clear_state); void Render(); diff --git a/pcsx2/ImGui/FullscreenUI_Internal.h b/pcsx2/ImGui/FullscreenUI_Internal.h index c7e42c08dc..13a14dfa8a 100644 --- a/pcsx2/ImGui/FullscreenUI_Internal.h +++ b/pcsx2/ImGui/FullscreenUI_Internal.h @@ -368,6 +368,7 @@ namespace FullscreenUI inline std::unordered_map s_cover_image_map; inline std::vector s_game_list_sorted_entries; inline GameListView s_game_list_view = GameListView::Grid; + inline bool s_game_list_needs_sort = true; // Cached list of unsorted game list entries; used to detect changes and re-sort when needed inline std::vector s_last_unsorted_entries; diff --git a/tests/ctest/core/StubHost.cpp b/tests/ctest/core/StubHost.cpp index e5f5676efa..d3a6615ad3 100644 --- a/tests/ctest/core/StubHost.cpp +++ b/tests/ctest/core/StubHost.cpp @@ -173,6 +173,10 @@ void Host::CancelGameListRefresh() { } +void Host::OnGameListFavoriteChanged(const std::string& path) +{ +} + bool Host::IsFullscreen() { return false;