Merge pull request 'feat: Add game hiding, launch animations, and accent color theming' (#116) from feat/customized_game_list into main

Reviewed-on: https://git.citron-emu.org/Citron/Emulator/pulls/116
This commit is contained in:
Collecting
2026-01-29 01:22:46 +01:00
6 changed files with 478 additions and 208 deletions

View File

@@ -299,6 +299,16 @@ void QtConfig::ReadUIGamelistValues() {
} }
EndArray(); EndArray();
const int hidden_paths_size = BeginArray("hidden_paths");
for (int i = 0; i < hidden_paths_size; ++i) {
SetArrayIndex(i);
const std::string path = ReadStringSetting(std::string("path"));
if (!path.empty()) {
UISettings::values.hidden_paths.append(QString::fromStdString(path));
}
}
EndArray();
EndGroup(); EndGroup();
} }
@@ -499,6 +509,13 @@ void QtConfig::SaveUIGamelistValues() {
} }
EndArray(); // favorites EndArray(); // favorites
BeginArray(std::string("hidden_paths"));
for (int i = 0; i < UISettings::values.hidden_paths.size(); ++i) {
SetArrayIndex(i);
WriteStringSetting(std::string("path"), UISettings::values.hidden_paths[i].toStdString());
}
EndArray(); // hidden_paths
EndGroup(); EndGroup();
} }

View File

@@ -22,6 +22,11 @@
#include <QPainterPath> #include <QPainterPath>
#include <QProgressDialog> #include <QProgressDialog>
#include <QProgressBar> #include <QProgressBar>
#include <QPropertyAnimation>
#include <QSequentialAnimationGroup>
#include <QParallelAnimationGroup>
#include <QGraphicsOpacityEffect>
#include <random>
#include <QScrollBar> #include <QScrollBar>
#include <QStyle> #include <QStyle>
#include <QThreadPool> #include <QThreadPool>
@@ -457,19 +462,16 @@ void GameList::FilterGridView(const QString& filter_text) {
QStandardItemModel* hierarchical_model = item_model; QStandardItemModel* hierarchical_model = item_model;
QStandardItemModel* flat_model = nullptr; QStandardItemModel* flat_model = nullptr;
// Check if we can reuse the existing model
QAbstractItemModel* current_model = list_view->model(); QAbstractItemModel* current_model = list_view->model();
if (current_model && current_model != item_model) { if (current_model && current_model != item_model) {
QStandardItemModel* existing_flat = qobject_cast<QStandardItemModel*>(current_model); QStandardItemModel* existing_flat = qobject_cast<QStandardItemModel*>(current_model);
if (existing_flat) { if (existing_flat) {
// Clear existing model instead of deleting it to avoid view flicker
existing_flat->clear(); existing_flat->clear();
flat_model = existing_flat; flat_model = existing_flat;
} }
} }
if (!flat_model) { if (!flat_model) {
// Delete old model if it exists and create new one
if (current_model && current_model != item_model) { if (current_model && current_model != item_model) {
current_model->deleteLater(); current_model->deleteLater();
} }
@@ -479,48 +481,45 @@ void GameList::FilterGridView(const QString& filter_text) {
int total_count = 0; int total_count = 0;
for (int i = 0; i < hierarchical_model->rowCount(); ++i) { for (int i = 0; i < hierarchical_model->rowCount(); ++i) {
QStandardItem* folder = hierarchical_model->item(i, 0); QStandardItem* folder = hierarchical_model->item(i, 0);
if (!folder) continue; if (!folder || folder->data(GameListItem::TypeRole).value<GameListItemType>() == GameListItemType::AddDir) {
const auto folder_type = folder->data(GameListItem::TypeRole).value<GameListItemType>();
if (folder_type == GameListItemType::AddDir) {
continue; continue;
} }
for (int j = 0; j < folder->rowCount(); ++j) { for (int j = 0; j < folder->rowCount(); ++j) {
QStandardItem* game_item = folder->child(j, 0); QStandardItem* game_item = folder->child(j, 0);
if (!game_item) continue; if (!game_item || game_item->data(GameListItem::TypeRole).value<GameListItemType>() != GameListItemType::Game) continue;
const auto game_type = game_item->data(GameListItem::TypeRole).value<GameListItemType>();
if (game_type == GameListItemType::Game) { total_count++;
total_count++; const QString full_path = game_item->data(GameListItemPath::FullPathRole).toString();
bool should_show = true; bool should_show = !UISettings::values.hidden_paths.contains(full_path);
if (!filter_text.isEmpty()) {
const QString file_path = game_item->data(GameListItemPath::FullPathRole).toString().toLower(); if (should_show && !filter_text.isEmpty()) {
const QString file_title = game_item->data(GameListItemPath::TitleRole).toString().toLower(); const QString file_title = game_item->data(GameListItemPath::TitleRole).toString().toLower();
const auto program_id = game_item->data(GameListItemPath::ProgramIdRole).toULongLong(); 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_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; const QString file_name = full_path.mid(full_path.lastIndexOf(QLatin1Char{'/'}) + 1).toLower() + QLatin1Char{' '} + file_title;
should_show = ContainsAllWords(file_name, filter_text) || (file_program_id.size() == 16 && file_program_id.contains(filter_text)); should_show = ContainsAllWords(file_name, filter_text) || (file_program_id.size() == 16 && file_program_id.contains(filter_text));
} }
if (should_show) {
QStandardItem* cloned_item = game_item->clone(); if (should_show) {
QString game_title = game_item->data(GameListItemPath::TitleRole).toString(); QStandardItem* cloned_item = game_item->clone();
if (game_title.isEmpty()) { QString game_title = game_item->data(GameListItemPath::TitleRole).toString();
std::string filename; if (game_title.isEmpty()) {
Common::SplitPath(game_item->data(GameListItemPath::FullPathRole).toString().toStdString(), nullptr, &filename, nullptr); std::string filename;
game_title = QString::fromStdString(filename); Common::SplitPath(full_path.toStdString(), nullptr, &filename, nullptr);
} game_title = QString::fromStdString(filename);
cloned_item->setText(game_title);
flat_model->appendRow(cloned_item);
visible_count++;
} }
cloned_item->setText(game_title);
flat_model->appendRow(cloned_item);
visible_count++;
} }
} }
} }
list_view->setModel(flat_model); list_view->setModel(flat_model);
const u32 icon_size = UISettings::values.game_icon_size.GetValue(); const u32 icon_size = UISettings::values.game_icon_size.GetValue();
list_view->setGridSize(QSize(icon_size + 60, icon_size + 80)); list_view->setGridSize(QSize(icon_size + 60, icon_size + 80));
// Set sort role and sort the filtered model
flat_model->setSortRole(GameListItemPath::SortRole); flat_model->setSortRole(GameListItemPath::SortRole);
flat_model->sort(0, current_sort_order); flat_model->sort(0, current_sort_order);
// Update icon sizes in the model - ensure all icons are consistently sized with rounded corners
for (int i = 0; i < flat_model->rowCount(); ++i) { for (int i = 0; i < flat_model->rowCount(); ++i) {
QStandardItem* item = flat_model->item(i); QStandardItem* item = flat_model->item(i);
if (item) { if (item) {
@@ -529,25 +528,19 @@ void GameList::FilterGridView(const QString& filter_text) {
QPixmap pixmap = icon_data.value<QPixmap>(); QPixmap pixmap = icon_data.value<QPixmap>();
if (!pixmap.isNull()) { if (!pixmap.isNull()) {
#ifdef __linux__ #ifdef __linux__
// On Linux, use simple scaling to avoid QPainter bugs
QPixmap scaled = pixmap.scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); QPixmap scaled = pixmap.scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
item->setData(scaled, Qt::DecorationRole); item->setData(scaled, Qt::DecorationRole);
#else #else
// On other platforms, use the QPainter method for rounded corners
QPixmap rounded(icon_size, icon_size); QPixmap rounded(icon_size, icon_size);
rounded.fill(Qt::transparent); rounded.fill(Qt::transparent);
QPainter painter(&rounded); QPainter painter(&rounded);
painter.setRenderHint(QPainter::Antialiasing); painter.setRenderHint(QPainter::Antialiasing);
const int radius = icon_size / 8; const int radius = icon_size / 8;
QPainterPath path; QPainterPath path;
path.addRoundedRect(0, 0, icon_size, icon_size, radius, radius); path.addRoundedRect(0, 0, icon_size, icon_size, radius, radius);
painter.setClipPath(path); painter.setClipPath(path);
QPixmap scaled = pixmap.scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); QPixmap scaled = pixmap.scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
painter.drawPixmap(0, 0, scaled); painter.drawPixmap(0, 0, scaled);
item->setData(rounded, Qt::DecorationRole); item->setData(rounded, Qt::DecorationRole);
#endif #endif
} }
@@ -558,45 +551,42 @@ void GameList::FilterGridView(const QString& filter_text) {
} }
void GameList::FilterTreeView(const QString& filter_text) { void GameList::FilterTreeView(const QString& filter_text) {
QStandardItem* folder; int visible_count = 0;
int children_total = 0; int total_count = 0;
if (filter_text.isEmpty()) {
tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), UISettings::values.favorited_ids.size() == 0); tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), filter_text.isEmpty() ? (UISettings::values.favorited_ids.size() == 0) : true);
for (int i = 1; i < item_model->rowCount() - 1; ++i) {
folder = item_model->item(i, 0); for (int i = 0; i < item_model->rowCount(); ++i) {
const QModelIndex folder_index = folder->index(); QStandardItem* folder = item_model->item(i, 0);
const int children_count = folder->rowCount(); if (!folder) continue;
for (int j = 0; j < children_count; ++j) {
++children_total; const QModelIndex folder_index = folder->index();
tree_view->setRowHidden(j, folder_index, false); for (int j = 0; j < folder->rowCount(); ++j) {
} const QStandardItem* child = folder->child(j, 0);
} if (!child) continue;
search_field->setFilterResult(children_total, children_total);
} else { total_count++;
tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true); const QString full_path = child->data(GameListItemPath::FullPathRole).toString();
int result_count = 0; bool is_hidden_by_user = UISettings::values.hidden_paths.contains(full_path);
for (int i = 1; i < item_model->rowCount() - 1; ++i) { bool matches_filter = true;
folder = item_model->item(i, 0);
const QModelIndex folder_index = folder->index(); if (!filter_text.isEmpty()) {
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 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_title = child->data(GameListItemPath::TitleRole).toString().toLower();
const QString file_program_id = QStringLiteral("%1").arg(program_id, 16, 16, QLatin1Char{'0'}); 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; const QString file_name = full_path.mid(full_path.lastIndexOf(QLatin1Char{'/'}) + 1).toLower() + QLatin1Char{' '} + file_title;
if (ContainsAllWords(file_name, filter_text) || (file_program_id.size() == 16 && file_program_id.contains(filter_text))) { matches_filter = ContainsAllWords(file_name, filter_text) || (file_program_id.size() == 16 && file_program_id.contains(filter_text));
tree_view->setRowHidden(j, folder_index, false); }
++result_count;
} else { if (!is_hidden_by_user && matches_filter) {
tree_view->setRowHidden(j, folder_index, true); tree_view->setRowHidden(j, folder_index, false);
} visible_count++;
} else {
tree_view->setRowHidden(j, folder_index, true);
} }
} }
search_field->setFilterResult(result_count, children_total);
} }
search_field->setFilterResult(visible_count, total_count);
} }
void GameList::OnUpdateThemedIcons() { void GameList::OnUpdateThemedIcons() {
@@ -968,7 +958,18 @@ play_time_manager{play_time_manager_}, system{system_} {
config_update_timer.setSingleShot(true); config_update_timer.setSingleShot(true);
connect(&config_update_timer, &QTimer::timeout, this, &GameList::UpdateOnlineStatus); connect(&config_update_timer, &QTimer::timeout, this, &GameList::UpdateOnlineStatus);
// This connection handles live updates when OK/Apply is clicked in the config window.
connect(main_window, &GMainWindow::ConfigurationSaved, this, &GameList::UpdateAccentColorStyles);
network_manager = new QNetworkAccessManager(this); network_manager = new QNetworkAccessManager(this);
fade_overlay = new QWidget(this);
fade_overlay->setStyleSheet(QStringLiteral("background: black;"));
fade_overlay->hide(); // Start hidden
connect(main_window, &GMainWindow::EmulationStopping, this, [this]() { OnEmulationEnded(); });
UpdateAccentColorStyles();
} }
void GameList::OnConfigurationChanged() { void GameList::OnConfigurationChanged() {
@@ -1086,6 +1087,118 @@ void GameList::OnOnlineStatusUpdated(const std::map<u64, std::pair<int, int>>& o
} }
} }
void GameList::StartLaunchAnimation(const QModelIndex& item) {
const QString file_path = item.data(GameListItemPath::FullPathRole).toString();
if (file_path.isEmpty()) return;
u64 program_id = item.data(GameListItemPath::ProgramIdRole).toULongLong();
QStandardItem* original_item = nullptr;
for (int folder_idx = 0; folder_idx < item_model->rowCount(); ++folder_idx) {
QStandardItem* folder = item_model->item(folder_idx, 0);
if (!folder) continue;
for (int game_idx = 0; game_idx < folder->rowCount(); ++game_idx) {
QStandardItem* game = folder->child(game_idx, 0);
if (game && game->data(GameListItemPath::ProgramIdRole).toULongLong() == program_id) {
original_item = game;
break;
}
}
if (original_item) break;
}
QPixmap icon;
if (original_item) {
icon = original_item->data(Qt::DecorationRole).value<QPixmap>();
} else {
// Fallback for safety
icon = item.data(Qt::DecorationRole).value<QPixmap>();
}
// If we still have no icon, launch instantly without animation
if (icon.isNull()) {
const auto title_id = item.data(GameListItemPath::ProgramIdRole).toULongLong();
emit GameChosen(file_path, title_id);
return;
}
// --- 2. FADE GAME LIST TO BLACK ---
fade_overlay->setGeometry(rect()); // Ensure size is correct
fade_overlay->raise();
fade_overlay->show();
auto* list_fade_effect = new QGraphicsOpacityEffect(fade_overlay);
fade_overlay->setGraphicsEffect(list_fade_effect);
auto* list_fade_in_anim = new QPropertyAnimation(list_fade_effect, "opacity");
list_fade_in_anim->setDuration(400); // Sync with icon zoom
list_fade_in_anim->setStartValue(0.0);
list_fade_in_anim->setEndValue(1.0);
list_fade_in_anim->setEasingCurve(QEasingCurve::OutCubic);
list_fade_in_anim->start(QAbstractAnimation::DeleteWhenStopped);
// --- 3. ICON ANIMATION ---
const auto title_id = item.data(GameListItemPath::ProgramIdRole).toULongLong();
QRect start_geom;
if (tree_view->isVisible()) {
start_geom = tree_view->visualRect(item.sibling(item.row(), 0));
start_geom.setTopLeft(tree_view->viewport()->mapTo(main_window, start_geom.topLeft()));
} else {
start_geom = list_view->visualRect(item);
start_geom.setTopLeft(list_view->viewport()->mapTo(main_window, start_geom.topLeft()));
}
auto* animation_label = new QLabel(main_window);
animation_label->setPixmap(icon);
animation_label->setScaledContents(true);
animation_label->setGeometry(start_geom);
animation_label->show();
animation_label->raise();
const int target_size = 256; // Use full 256x256 resolution
const QPoint center_point = main_window->rect().center();
QRect zoom_end_geom(0, 0, target_size, target_size);
zoom_end_geom.moveCenter(center_point);
QRect fly_end_geom = zoom_end_geom;
fly_end_geom.moveCenter(QPoint(center_point.x(), -target_size));
auto* zoom_anim = new QPropertyAnimation(animation_label, "geometry");
zoom_anim->setDuration(400);
zoom_anim->setStartValue(start_geom);
zoom_anim->setEndValue(zoom_end_geom);
zoom_anim->setEasingCurve(QEasingCurve::OutCubic);
auto* fly_fade_group = new QParallelAnimationGroup;
auto* effect = new QGraphicsOpacityEffect(animation_label);
animation_label->setGraphicsEffect(effect);
auto* fly_anim = new QPropertyAnimation(animation_label, "geometry");
fly_anim->setDuration(350);
fly_anim->setStartValue(zoom_end_geom);
fly_anim->setEndValue(fly_end_geom);
fly_anim->setEasingCurve(QEasingCurve::InQuad);
auto* fade_anim = new QPropertyAnimation(effect, "opacity");
fade_anim->setDuration(350);
fade_anim->setStartValue(1.0);
fade_anim->setEndValue(0.0);
fade_anim->setEasingCurve(QEasingCurve::InQuad);
fly_fade_group->addAnimation(fly_anim);
fly_fade_group->addAnimation(fade_anim);
auto* main_group = new QSequentialAnimationGroup(animation_label);
main_group->addAnimation(zoom_anim);
main_group->addPause(50);
main_group->addAnimation(fly_fade_group);
// When the icon animation finishes, launch the game and clean up.
// The black overlay will remain until OnEmulationEnded is called.
connect(main_group, &QSequentialAnimationGroup::finished, this, [this, file_path, title_id, animation_label]() {
search_field->clear();
emit GameChosen(file_path, title_id);
animation_label->deleteLater();
});
main_group->start(QAbstractAnimation::DeleteWhenStopped);
}
void GameList::ValidateEntry(const QModelIndex& item) { void GameList::ValidateEntry(const QModelIndex& item) {
const auto selected = item.sibling(item.row(), 0); const auto selected = item.sibling(item.row(), 0);
switch (selected.data(GameListItem::TypeRole).value<GameListItemType>()) { switch (selected.data(GameListItem::TypeRole).value<GameListItemType>()) {
@@ -1094,17 +1207,20 @@ void GameList::ValidateEntry(const QModelIndex& item) {
if (file_path.isEmpty()) return; if (file_path.isEmpty()) return;
const QFileInfo file_info(file_path); const QFileInfo file_info(file_path);
if (!file_info.exists()) return; if (!file_info.exists()) return;
// If the entry is a directory, launch it directly without animation.
if (file_info.isDir()) { if (file_info.isDir()) {
const QDir dir{file_path}; const QDir dir{file_path};
const QStringList matching_main = dir.entryList({QStringLiteral("main")}, QDir::Files); const QStringList matching_main = dir.entryList({QStringLiteral("main")}, QDir::Files);
if (matching_main.size() == 1) { if (matching_main.size() == 1) {
emit GameChosen(dir.path() + QDir::separator() + matching_main[0]); emit GameChosen(dir.path() + QDir::separator() + matching_main[0]);
} }
return; return; // Exit here for directories
} }
const auto title_id = selected.data(GameListItemPath::ProgramIdRole).toULongLong();
search_field->clear(); // If it's a standard game file, trigger the new launch animation.
emit GameChosen(file_path, title_id); // The animation function will handle emitting GameChosen when it's finished.
StartLaunchAnimation(selected);
break; break;
} }
case GameListItemType::AddDir: case GameListItemType::AddDir:
@@ -1252,12 +1368,14 @@ void GameList::PopupContextMenu(const QPoint& menu_location) {
} }
} }
void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path, const QString& game_name) { void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path_str, const QString& game_name) {
const QString path = QString::fromStdString(path_str);
const bool is_mirrored = Settings::values.mirrored_save_paths.count(program_id); const bool is_mirrored = Settings::values.mirrored_save_paths.count(program_id);
const bool has_custom_path = Settings::values.custom_save_paths.count(program_id); const bool has_custom_path = Settings::values.custom_save_paths.count(program_id);
QString mirror_base_path; QString mirror_base_path;
QAction* favorite = context_menu.addAction(tr("Favorite")); QAction* favorite = context_menu.addAction(tr("Favorite"));
QAction* hide_game = context_menu.addAction(tr("Hide Game"));
context_menu.addSeparator(); context_menu.addSeparator();
QAction* start_game = context_menu.addAction(tr("Start Game")); QAction* start_game = context_menu.addAction(tr("Start Game"));
QAction* start_game_global = context_menu.addAction(tr("Start Game without Custom Configuration")); QAction* start_game_global = context_menu.addAction(tr("Start Game without Custom Configuration"));
@@ -1301,6 +1419,14 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
favorite->setVisible(program_id != 0); favorite->setVisible(program_id != 0);
favorite->setCheckable(true); favorite->setCheckable(true);
favorite->setChecked(UISettings::values.favorited_ids.contains(program_id)); favorite->setChecked(UISettings::values.favorited_ids.contains(program_id));
hide_game->setVisible(program_id != 0);
hide_game->setCheckable(true);
hide_game->setChecked(UISettings::values.hidden_paths.contains(path));
if (hide_game->isChecked()) {
hide_game->setText(tr("Unhide Game"));
}
open_save_location->setVisible(program_id != 0); open_save_location->setVisible(program_id != 0);
open_nand_location->setVisible(is_mirrored); open_nand_location->setVisible(is_mirrored);
open_nand_location->setToolTip(tr("Citron uses your NAND while syncing. If you need to make save data modifications, do so in here.")); open_nand_location->setToolTip(tr("Citron uses your NAND while syncing. If you need to make save data modifications, do so in here."));
@@ -1326,24 +1452,17 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
open_nand_location->setToolTip(tr("The global save path is being used as the base for save data mirroring.")); open_nand_location->setToolTip(tr("The global save path is being used as the base for save data mirroring."));
mirror_base_path = QString::fromStdString(Settings::values.global_custom_save_path.GetValue()); mirror_base_path = QString::fromStdString(Settings::values.global_custom_save_path.GetValue());
} else { } else {
// Text is already "Open NAND Location", so we just set the correct path and a more descriptive tooltip.
open_nand_location->setToolTip(tr("Citron's default NAND is being used as the base for save data mirroring.")); open_nand_location->setToolTip(tr("Citron's default NAND is being used as the base for save data mirroring."));
mirror_base_path = QString::fromStdString(Common::FS::GetCitronPathString(Common::FS::CitronPath::NANDDir)); mirror_base_path = QString::fromStdString(Common::FS::GetCitronPathString(Common::FS::CitronPath::NANDDir));
} }
connect(open_nand_location, &QAction::triggered, [this, program_id, mirror_base_path]() { connect(open_nand_location, &QAction::triggered, [this, program_id, mirror_base_path]() {
const auto user_id = system.GetProfileManager().GetLastOpenedUser().AsU128(); const auto user_id = system.GetProfileManager().GetLastOpenedUser().AsU128();
// This constructs the relative path to the specific game's save folder
const std::string relative_save_path = fmt::format("user/save/{:016X}/{:016X}{:016X}/{:016X}", 0, user_id[1], user_id[0], program_id); const std::string relative_save_path = fmt::format("user/save/{:016X}/{:016X}{:016X}/{:016X}", 0, user_id[1], user_id[0], program_id);
// Combine the determined base path (Global or default NAND) with the relative game path
const auto full_save_path = std::filesystem::path(mirror_base_path.toStdString()) / relative_save_path; const auto full_save_path = std::filesystem::path(mirror_base_path.toStdString()) / relative_save_path;
// Ensure the parent directory exists before trying to open it
if (!std::filesystem::exists(full_save_path.parent_path())) { if (!std::filesystem::exists(full_save_path.parent_path())) {
std::filesystem::create_directories(full_save_path.parent_path()); std::filesystem::create_directories(full_save_path.parent_path());
} }
QDesktopServices::openUrl(QUrl::fromLocalFile(QString::fromStdString(full_save_path.string()))); QDesktopServices::openUrl(QUrl::fromLocalFile(QString::fromStdString(full_save_path.string())));
}); });
} }
@@ -1351,7 +1470,8 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
submit_compat_report->setToolTip(tr("Requires GitHub account.")); submit_compat_report->setToolTip(tr("Requires GitHub account."));
connect(favorite, &QAction::triggered, [this, program_id]() { ToggleFavorite(program_id); }); connect(favorite, &QAction::triggered, [this, program_id]() { ToggleFavorite(program_id); });
connect(open_save_location, &QAction::triggered, [this, program_id, path]() { emit OpenFolderRequested(program_id, GameListOpenTarget::SaveData, path); }); connect(hide_game, &QAction::triggered, [this, path]() { ToggleHidden(path); });
connect(open_save_location, &QAction::triggered, [this, program_id, path_str]() { emit OpenFolderRequested(program_id, GameListOpenTarget::SaveData, path_str); });
auto calculateTotalSize = [](const QString& dirPath) -> qint64 { auto calculateTotalSize = [](const QString& dirPath) -> qint64 {
qint64 totalSize = 0; qint64 totalSize = 0;
@@ -1372,30 +1492,23 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
progress.setWindowModality(Qt::WindowModal); progress.setWindowModality(Qt::WindowModal);
progress.setMinimumDuration(0); progress.setMinimumDuration(0);
progress.setValue(0); progress.setValue(0);
qint64 totalSize = calculateTotalSize(sourceDir); qint64 totalSize = calculateTotalSize(sourceDir);
qint64 copiedSize = 0; qint64 copiedSize = 0;
QDir dir(sourceDir); QDir dir(sourceDir);
if (!dir.exists()) return false; if (!dir.exists()) return false;
QDir dest_dir(destDir); QDir dest_dir(destDir);
if (!dest_dir.exists()) dest_dir.mkpath(QStringLiteral(".")); if (!dest_dir.exists()) dest_dir.mkpath(QStringLiteral("."));
QDirIterator dir_iter(sourceDir, QDirIterator::Subdirectories); QDirIterator dir_iter(sourceDir, QDirIterator::Subdirectories);
while (dir_iter.hasNext()) { while (dir_iter.hasNext()) {
dir_iter.next(); dir_iter.next();
const QFileInfo file_info = dir_iter.fileInfo(); const QFileInfo file_info = dir_iter.fileInfo();
const QString relative_path = dir.relativeFilePath(file_info.absoluteFilePath()); const QString relative_path = dir.relativeFilePath(file_info.absoluteFilePath());
const QString dest_path = QDir(destDir).filePath(relative_path); const QString dest_path = QDir(destDir).filePath(relative_path);
if (file_info.isDir()) { if (file_info.isDir()) {
dest_dir.mkpath(dest_path); dest_dir.mkpath(dest_path);
} else if (file_info.isFile()) { } else if (file_info.isFile()) {
if (QFile::exists(dest_path)) QFile::remove(dest_path); if (QFile::exists(dest_path)) QFile::remove(dest_path);
if (!QFile::copy(file_info.absoluteFilePath(), dest_path)) return false; if (!QFile::copy(file_info.absoluteFilePath(), dest_path)) return false;
copiedSize += file_info.size(); copiedSize += file_info.size();
if (totalSize > 0) { if (totalSize > 0) {
progress.setValue(static_cast<int>((copiedSize * 100) / totalSize)); progress.setValue(static_cast<int>((copiedSize * 100) / totalSize));
@@ -1407,99 +1520,74 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
return true; return true;
}; };
connect(set_custom_save_path, &QAction::triggered, [this, program_id, copyWithProgress]() { connect(set_custom_save_path, &QAction::triggered, [this, program_id, copyWithProgress]() {
const QString new_path = QFileDialog::getExistingDirectory(this, tr("Select Custom Save Data Location")); const QString new_path = QFileDialog::getExistingDirectory(this, tr("Select Custom Save Data Location"));
if (new_path.isEmpty()) return; if (new_path.isEmpty()) return;
std::string base_save_path_str;
std::string base_save_path_str; if (Settings::values.global_custom_save_path_enabled.GetValue() &&
if (Settings::values.global_custom_save_path_enabled.GetValue() && !Settings::values.global_custom_save_path.GetValue().empty()) {
!Settings::values.global_custom_save_path.GetValue().empty()) { base_save_path_str = Settings::values.global_custom_save_path.GetValue();
base_save_path_str = Settings::values.global_custom_save_path.GetValue();
} else {
base_save_path_str = Common::FS::GetCitronPathString(Common::FS::CitronPath::NANDDir);
}
const QString base_dir = QString::fromStdString(base_save_path_str);
const auto user_id = system.GetProfileManager().GetLastOpenedUser().AsU128();
const std::string relative_save_path = fmt::format("user/save/{:016X}/{:016X}{:016X}/{:016X}", 0, user_id[1], user_id[0], program_id);
// This path points to the save data within either the Global Path or the NAND.
const QString internal_save_path = QDir(base_dir).filePath(QString::fromStdString(relative_save_path));
bool mirroring_enabled = false;
// The check for other emulators uses the determined base directory.
QString detected_emu = GetDetectedEmulatorName(new_path, program_id, base_dir);
if (!detected_emu.isEmpty()) {
QMessageBox::StandardButton mirror_reply = QMessageBox::question(this, tr("Enable Save Mirroring?"),
tr("Citron has detected a %1 save structure.\n\n"
"Would you like to enable 'Intelligent Mirroring'? This will pull the data into Citron's internal save directory "
"(currently set to '%2') and keep both locations synced whenever you play. A backup of your existing Citron data "
"will be created. BE WARNED: Please do not have both emulators open during this process.").arg(detected_emu, base_dir),
QMessageBox::Yes | QMessageBox::No);
if (mirror_reply == QMessageBox::Yes) {
mirroring_enabled = true;
}
}
QDir internal_dir(internal_save_path);
if (internal_dir.exists() && !internal_dir.isEmpty()) {
if (mirroring_enabled) {
// Non-destructive backup for mirroring, now created in the base directory.
QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd_hh-mm-ss"));
QString backup_path = internal_save_path + QStringLiteral("_mirror_backup_") + timestamp;
// Ensure parent directory exists before renaming
QDir().mkpath(QFileInfo(backup_path).absolutePath());
if (QDir().rename(internal_save_path, backup_path)) {
LOG_INFO(Frontend, "Safety: Existing internal data moved to backup: {}", backup_path.toStdString());
}
} else { } else {
// Standard Citron behavior for manual paths (Override mode) base_save_path_str = Common::FS::GetCitronPathString(Common::FS::CitronPath::NANDDir);
QMessageBox::StandardButton reply = QMessageBox::question(this, tr("Move Save Data"), }
tr("You have existing save data in your internal save directory. Would you like to move it to the new custom save path?"), const QString base_dir = QString::fromStdString(base_save_path_str);
QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel); const auto user_id = system.GetProfileManager().GetLastOpenedUser().AsU128();
const std::string relative_save_path = fmt::format("user/save/{:016X}/{:016X}{:016X}/{:016X}", 0, user_id[1], user_id[0], program_id);
const QString internal_save_path = QDir(base_dir).filePath(QString::fromStdString(relative_save_path));
bool mirroring_enabled = false;
QString detected_emu = GetDetectedEmulatorName(new_path, program_id, base_dir);
if (!detected_emu.isEmpty()) {
QMessageBox::StandardButton mirror_reply = QMessageBox::question(this, tr("Enable Save Mirroring?"),
tr("Citron has detected a %1 save structure.\n\n"
"Would you like to enable 'Intelligent Mirroring'? This will pull the data into Citron's internal save directory "
"(currently set to '%2') and keep both locations synced whenever you play. A backup of your existing Citron data "
"will be created. BE WARNED: Please do not have both emulators open during this process.").arg(detected_emu, base_dir),
QMessageBox::Yes | QMessageBox::No);
if (reply == QMessageBox::Cancel) return; if (mirror_reply == QMessageBox::Yes) {
mirroring_enabled = true;
if (reply == QMessageBox::Yes) { }
// In override mode, we move files TO the new path }
const QString full_dest_path = QDir(new_path).filePath(QString::fromStdString(relative_save_path)); QDir internal_dir(internal_save_path);
if (copyWithProgress(internal_save_path, full_dest_path, this)) { if (internal_dir.exists() && !internal_dir.isEmpty()) {
QDir(internal_save_path).removeRecursively(); if (mirroring_enabled) {
QMessageBox::information(this, tr("Success"), tr("Successfully moved save data to the new location.")); QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd_hh-mm-ss"));
} else { QString backup_path = internal_save_path + QStringLiteral("_mirror_backup_") + timestamp;
QMessageBox::warning(this, tr("Error"), tr("Failed to move save data. Please see the log for more details.")); QDir().mkpath(QFileInfo(backup_path).absolutePath());
if (QDir().rename(internal_save_path, backup_path)) {
LOG_INFO(Frontend, "Safety: Existing internal data moved to backup: {}", backup_path.toStdString());
}
} else {
QMessageBox::StandardButton reply = QMessageBox::question(this, tr("Move Save Data"),
tr("You have existing save data in your internal save directory. Would you like to move it to the new custom save path?"),
QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel);
if (reply == QMessageBox::Cancel) return;
if (reply == QMessageBox::Yes) {
const QString full_dest_path = QDir(new_path).filePath(QString::fromStdString(relative_save_path));
if (copyWithProgress(internal_save_path, full_dest_path, this)) {
QDir(internal_save_path).removeRecursively();
QMessageBox::information(this, tr("Success"), tr("Successfully moved save data to the new location."));
} else {
QMessageBox::warning(this, tr("Error"), tr("Failed to move save data. Please see the log for more details."));
}
} }
} }
} }
} if (mirroring_enabled) {
if (copyWithProgress(new_path, internal_save_path, this)) {
if (mirroring_enabled) { Settings::values.mirrored_save_paths.insert_or_assign(program_id, new_path.toStdString());
// Initial Pull (External -> Internal) Settings::values.custom_save_paths.erase(program_id);
// We copy FROM the selected folder TO the correct internal save location. QMessageBox::information(this, tr("Success"), tr("Mirroring established. Your data has been pulled into the internal Citron save directory."));
if (copyWithProgress(new_path, internal_save_path, this)) { } else {
// IMPORTANT: Save to the NEW mirror map QMessageBox::warning(this, tr("Error"), tr("Failed to pull data from the mirror source."));
Settings::values.mirrored_save_paths.insert_or_assign(program_id, new_path.toStdString()); return;
// CLEAR the standard custom path so the emulator boots from the internal directory }
Settings::values.custom_save_paths.erase(program_id);
QMessageBox::information(this, tr("Success"), tr("Mirroring established. Your data has been pulled into the internal Citron save directory."));
} else { } else {
QMessageBox::warning(this, tr("Error"), tr("Failed to pull data from the mirror source.")); Settings::values.custom_save_paths.insert_or_assign(program_id, new_path.toStdString());
return; Settings::values.mirrored_save_paths.erase(program_id);
} }
} else { emit SaveConfig();
// Standard Path Override });
Settings::values.custom_save_paths.insert_or_assign(program_id, new_path.toStdString());
// Remove from mirror map if it was there before
Settings::values.mirrored_save_paths.erase(program_id);
}
emit SaveConfig();
});
connect(disable_mirroring, &QAction::triggered, [this, program_id]() { connect(disable_mirroring, &QAction::triggered, [this, program_id]() {
if (QMessageBox::question(this, tr("Disable Mirroring"), if (QMessageBox::question(this, tr("Disable Mirroring"),
@@ -1511,85 +1599,85 @@ connect(set_custom_save_path, &QAction::triggered, [this, program_id, copyWithPr
tr("Mirroring has been disabled for this game. It will now use the save data from the NAND.")); tr("Mirroring has been disabled for this game. It will now use the save data from the NAND."));
} }
}); });
connect(open_current_game_sdmc, &QAction::triggered, [program_id]() { connect(open_current_game_sdmc, &QAction::triggered, [program_id]() {
const auto sdmc_path = Common::FS::GetCitronPath(Common::FS::CitronPath::SDMCDir); const auto sdmc_path = Common::FS::GetCitronPath(Common::FS::CitronPath::SDMCDir);
const auto full_path = sdmc_path / "atmosphere" / "contents" / fmt::format("{:016X}", program_id); const auto full_path = sdmc_path / "atmosphere" / "contents" / fmt::format("{:016X}", program_id);
const QString qpath = QString::fromStdString(Common::FS::PathToUTF8String(full_path)); const QString qpath = QString::fromStdString(Common::FS::PathToUTF8String(full_path));
QDir dir(qpath); QDir dir(qpath);
if (!dir.exists()) dir.mkpath(QStringLiteral(".")); if (!dir.exists()) dir.mkpath(QStringLiteral("."));
QDesktopServices::openUrl(QUrl::fromLocalFile(qpath)); QDesktopServices::openUrl(QUrl::fromLocalFile(qpath));
}); });
connect(open_full_sdmc, &QAction::triggered, []() { connect(open_full_sdmc, &QAction::triggered, []() {
const auto sdmc_path = Common::FS::GetCitronPath(Common::FS::CitronPath::SDMCDir); const auto sdmc_path = Common::FS::GetCitronPath(Common::FS::CitronPath::SDMCDir);
const auto full_path = sdmc_path / "atmosphere" / "contents"; const auto full_path = sdmc_path / "atmosphere" / "contents";
const QString qpath = QString::fromStdString(Common::FS::PathToUTF8String(full_path)); const QString qpath = QString::fromStdString(Common::FS::PathToUTF8String(full_path));
QDir dir(qpath); QDir dir(qpath);
if (!dir.exists()) dir.mkpath(QStringLiteral(".")); if (!dir.exists()) dir.mkpath(QStringLiteral("."));
QDesktopServices::openUrl(QUrl::fromLocalFile(qpath)); QDesktopServices::openUrl(QUrl::fromLocalFile(qpath));
}); });
connect(start_game, &QAction::triggered, [this, path_str]() { emit BootGame(QString::fromStdString(path_str), StartGameType::Normal); });
connect(start_game, &QAction::triggered, [this, path]() { emit BootGame(QString::fromStdString(path), StartGameType::Normal); }); connect(start_game_global, &QAction::triggered, [this, path_str]() { emit BootGame(QString::fromStdString(path_str), StartGameType::Global); });
connect(start_game_global, &QAction::triggered, [this, path]() { emit BootGame(QString::fromStdString(path), StartGameType::Global); }); connect(open_mod_location, &QAction::triggered, [this, program_id, path_str]() { emit OpenFolderRequested(program_id, GameListOpenTarget::ModData, path_str); });
connect(open_mod_location, &QAction::triggered, [this, program_id, path]() { emit OpenFolderRequested(program_id, GameListOpenTarget::ModData, path); });
connect(open_transferable_shader_cache, &QAction::triggered, [this, program_id]() { emit OpenTransferableShaderCacheRequested(program_id); }); connect(open_transferable_shader_cache, &QAction::triggered, [this, program_id]() { emit OpenTransferableShaderCacheRequested(program_id); });
connect(remove_all_content, &QAction::triggered, [this, program_id]() { emit RemoveInstalledEntryRequested(program_id, InstalledEntryType::Game); }); connect(remove_all_content, &QAction::triggered, [this, program_id]() { emit RemoveInstalledEntryRequested(program_id, InstalledEntryType::Game); });
connect(remove_update, &QAction::triggered, [this, program_id]() { emit RemoveInstalledEntryRequested(program_id, InstalledEntryType::Update); }); connect(remove_update, &QAction::triggered, [this, program_id]() { emit RemoveInstalledEntryRequested(program_id, InstalledEntryType::Update); });
connect(remove_dlc, &QAction::triggered, [this, program_id]() { emit RemoveInstalledEntryRequested(program_id, InstalledEntryType::AddOnContent); }); connect(remove_dlc, &QAction::triggered, [this, program_id]() { emit RemoveInstalledEntryRequested(program_id, InstalledEntryType::AddOnContent); });
connect(remove_gl_shader_cache, &QAction::triggered, [this, program_id, path]() { emit RemoveFileRequested(program_id, GameListRemoveTarget::GlShaderCache, path); }); connect(remove_gl_shader_cache, &QAction::triggered, [this, program_id, path_str]() { emit RemoveFileRequested(program_id, GameListRemoveTarget::GlShaderCache, path_str); });
connect(remove_vk_shader_cache, &QAction::triggered, [this, program_id, path]() { emit RemoveFileRequested(program_id, GameListRemoveTarget::VkShaderCache, path); }); connect(remove_vk_shader_cache, &QAction::triggered, [this, program_id, path_str]() { emit RemoveFileRequested(program_id, GameListRemoveTarget::VkShaderCache, path_str); });
connect(remove_shader_cache, &QAction::triggered, [this, program_id, path]() { emit RemoveFileRequested(program_id, GameListRemoveTarget::AllShaderCache, path); }); connect(remove_shader_cache, &QAction::triggered, [this, program_id, path_str]() { emit RemoveFileRequested(program_id, GameListRemoveTarget::AllShaderCache, path_str); });
connect(remove_custom_config, &QAction::triggered, [this, program_id, path]() { emit RemoveFileRequested(program_id, GameListRemoveTarget::CustomConfiguration, path); }); connect(remove_custom_config, &QAction::triggered, [this, program_id, path_str]() { emit RemoveFileRequested(program_id, GameListRemoveTarget::CustomConfiguration, path_str); });
connect(remove_play_time_data, &QAction::triggered, [this, program_id]() { emit RemovePlayTimeRequested(program_id); }); connect(remove_play_time_data, &QAction::triggered, [this, program_id]() { emit RemovePlayTimeRequested(program_id); });
connect(remove_cache_storage, &QAction::triggered, [this, program_id, path] { emit RemoveFileRequested(program_id, GameListRemoveTarget::CacheStorage, path); }); connect(remove_cache_storage, &QAction::triggered, [this, program_id, path_str] { emit RemoveFileRequested(program_id, GameListRemoveTarget::CacheStorage, path_str); });
connect(dump_romfs, &QAction::triggered, [this, program_id, path]() { emit DumpRomFSRequested(program_id, path, DumpRomFSTarget::Normal); }); connect(dump_romfs, &QAction::triggered, [this, program_id, path_str]() { emit DumpRomFSRequested(program_id, path_str, DumpRomFSTarget::Normal); });
connect(dump_romfs_sdmc, &QAction::triggered, [this, program_id, path]() { emit DumpRomFSRequested(program_id, path, DumpRomFSTarget::SDMC); }); connect(dump_romfs_sdmc, &QAction::triggered, [this, program_id, path_str]() { emit DumpRomFSRequested(program_id, path_str, DumpRomFSTarget::SDMC); });
connect(verify_integrity, &QAction::triggered, [this, path]() { emit VerifyIntegrityRequested(path); }); connect(verify_integrity, &QAction::triggered, [this, path_str]() { emit VerifyIntegrityRequested(path_str); });
connect(copy_tid, &QAction::triggered, [this, program_id]() { emit CopyTIDRequested(program_id); }); connect(copy_tid, &QAction::triggered, [this, program_id]() { emit CopyTIDRequested(program_id); });
// Logic for GitHub Reporting
connect(submit_compat_report, &QAction::triggered, [this, program_id, game_name]() { connect(submit_compat_report, &QAction::triggered, [this, program_id, game_name]() {
// 1. Show the warning message
const auto reply = QMessageBox::question(this, tr("GitHub Account Required"), const auto reply = QMessageBox::question(this, tr("GitHub Account Required"),
tr("In order to submit a compatibility report, you must have a GitHub account.\n\n" tr("In order to submit a compatibility report, you must have a GitHub account.\n\n"
"If you do not have one, this feature will not work. Would you like to proceed?"), "If you do not have one, this feature will not work. Would you like to proceed?"),
QMessageBox::Yes | QMessageBox::No); QMessageBox::Yes | QMessageBox::No);
if (reply != QMessageBox::Yes) { if (reply != QMessageBox::Yes) {
return; return;
} }
// 2. Build the minimal URL
const QString clean_tid = QStringLiteral("%1").arg(program_id, 16, 16, QLatin1Char('0')).toUpper(); const QString clean_tid = QStringLiteral("%1").arg(program_id, 16, 16, QLatin1Char('0')).toUpper();
QUrl url(QStringLiteral("https://github.com/CollectingW/Citron-Compatability/issues/new")); QUrl url(QStringLiteral("https://github.com/CollectingW/Citron-Compatability/issues/new"));
QUrlQuery query; QUrlQuery query;
query.addQueryItem(QStringLiteral("template"), QStringLiteral("compat.yml")); query.addQueryItem(QStringLiteral("template"), QStringLiteral("compat.yml"));
query.addQueryItem(QStringLiteral("title"), game_name); query.addQueryItem(QStringLiteral("title"), game_name);
query.addQueryItem(QStringLiteral("title_id"), clean_tid); query.addQueryItem(QStringLiteral("title_id"), clean_tid);
url.setQuery(query); url.setQuery(query);
// 3. Open the browser
QDesktopServices::openUrl(url); QDesktopServices::openUrl(url);
}); });
#if !defined(__APPLE__) #if !defined(__APPLE__)
connect(create_desktop_shortcut, &QAction::triggered, [this, program_id, path]() { emit CreateShortcut(program_id, path, GameListShortcutTarget::Desktop); }); connect(create_desktop_shortcut, &QAction::triggered, [this, program_id, path_str]() { emit CreateShortcut(program_id, path_str, GameListShortcutTarget::Desktop); });
connect(create_applications_menu_shortcut, &QAction::triggered, [this, program_id, path]() { emit CreateShortcut(program_id, path, GameListShortcutTarget::Applications); }); connect(create_applications_menu_shortcut, &QAction::triggered, [this, program_id, path_str]() { emit CreateShortcut(program_id, path_str, GameListShortcutTarget::Applications); });
#endif #endif
connect(properties, &QAction::triggered, [this, path]() { emit OpenPerGameGeneralRequested(path); }); connect(properties, &QAction::triggered, [this, path_str]() { emit OpenPerGameGeneralRequested(path_str); });
} }
void GameList::AddCustomDirPopup(QMenu& context_menu, QModelIndex selected) { void GameList::AddCustomDirPopup(QMenu& context_menu, QModelIndex selected) {
UISettings::GameDir& game_dir = UISettings::values.game_dirs[selected.data(GameListDir::GameDirRole).toInt()]; UISettings::GameDir& game_dir = UISettings::values.game_dirs[selected.data(GameListDir::GameDirRole).toInt()];
QAction* show_hidden = context_menu.addAction(tr("Show Hidden Games"));
context_menu.addSeparator();
QAction* deep_scan = context_menu.addAction(tr("Scan Subfolders")); QAction* deep_scan = context_menu.addAction(tr("Scan Subfolders"));
QAction* delete_dir = context_menu.addAction(tr("Remove Game Directory")); QAction* delete_dir = context_menu.addAction(tr("Remove Game Directory"));
deep_scan->setCheckable(true); deep_scan->setCheckable(true);
deep_scan->setChecked(game_dir.deep_scan); deep_scan->setChecked(game_dir.deep_scan);
connect(show_hidden, &QAction::triggered, [this, selected] {
QStandardItem* folder = item_model->itemFromIndex(selected);
bool changed = false;
for (int i = 0; i < folder->rowCount(); ++i) {
const QString path = folder->child(i)->data(GameListItemPath::FullPathRole).toString();
if (UISettings::values.hidden_paths.removeOne(path)) {
changed = true;
}
}
if (changed) {
OnTextChanged(search_field->filterText());
emit SaveConfig();
}
});
connect(deep_scan, &QAction::triggered, [this, &game_dir] { connect(deep_scan, &QAction::triggered, [this, &game_dir] {
game_dir.deep_scan = !game_dir.deep_scan; game_dir.deep_scan = !game_dir.deep_scan;
PopulateAsync(UISettings::values.game_dirs); PopulateAsync(UISettings::values.game_dirs);
@@ -1603,12 +1691,28 @@ void GameList::AddCustomDirPopup(QMenu& context_menu, QModelIndex selected) {
void GameList::AddPermDirPopup(QMenu& context_menu, QModelIndex selected) { void GameList::AddPermDirPopup(QMenu& context_menu, QModelIndex selected) {
const int game_dir_index = selected.data(GameListDir::GameDirRole).toInt(); const int game_dir_index = selected.data(GameListDir::GameDirRole).toInt();
QAction* show_hidden = context_menu.addAction(tr("Show Hidden Games"));
context_menu.addSeparator();
QAction* move_up = context_menu.addAction(tr("\u25B2 Move Up")); QAction* move_up = context_menu.addAction(tr("\u25B2 Move Up"));
QAction* move_down = context_menu.addAction(tr("\u25bc Move Down")); QAction* move_down = context_menu.addAction(tr("\u25bc Move Down"));
QAction* open_directory_location = context_menu.addAction(tr("Open Directory Location")); QAction* open_directory_location = context_menu.addAction(tr("Open Directory Location"));
const int row = selected.row(); const int row = selected.row();
move_up->setEnabled(row > 1); move_up->setEnabled(row > 1);
move_down->setEnabled(row < item_model->rowCount() - 2); move_down->setEnabled(row < item_model->rowCount() - 2);
connect(show_hidden, &QAction::triggered, [this, selected] {
QStandardItem* folder = item_model->itemFromIndex(selected);
bool changed = false;
for (int i = 0; i < folder->rowCount(); ++i) {
const QString path = folder->child(i)->data(GameListItemPath::FullPathRole).toString();
if (UISettings::values.hidden_paths.removeOne(path)) {
changed = true;
}
}
if (changed) {
OnTextChanged(search_field->filterText());
emit SaveConfig();
}
});
connect(move_up, &QAction::triggered, [this, selected, row, game_dir_index] { connect(move_up, &QAction::triggered, [this, selected, row, game_dir_index] {
const int other_index = selected.sibling(row - 1, 0).data(GameListDir::GameDirRole).toInt(); const int other_index = selected.sibling(row - 1, 0).data(GameListDir::GameDirRole).toInt();
std::swap(UISettings::values.game_dirs[game_dir_index], UISettings::values.game_dirs[other_index]); std::swap(UISettings::values.game_dirs[game_dir_index], UISettings::values.game_dirs[other_index]);
@@ -2175,4 +2279,139 @@ void GameList::onSurpriseMeClicked() {
// If the user just closes the window (or clicks the 'X'), nothing happens. // If the user just closes the window (or clicks the 'X'), nothing happens.
} }
void GameList::UpdateAccentColorStyles() {
QColor accent_color(QString::fromStdString(UISettings::values.accent_color.GetValue()));
if (!accent_color.isValid()) {
accent_color = palette().color(QPalette::Highlight);
}
const QString color_name = accent_color.name();
// Create a semi-transparent version of the accent color for the SELECTION background
QColor selection_background_color = accent_color;
selection_background_color.setAlphaF(0.25); // 25% opacity for a clear selection
const QString selection_background_color_name = QStringLiteral("rgba(%1, %2, %3, %4)")
.arg(selection_background_color.red())
.arg(selection_background_color.green())
.arg(selection_background_color.blue())
.arg(selection_background_color.alpha());
// Create a MORE subtle semi-transparent version for the HOVER effect
QColor hover_background_color = accent_color;
hover_background_color.setAlphaF(0.15); // 15% opacity for a subtle hover
const QString hover_background_color_name = QStringLiteral("rgba(%1, %2, %3, %4)")
.arg(hover_background_color.red())
.arg(hover_background_color.green())
.arg(hover_background_color.blue())
.arg(hover_background_color.alpha());
QString accent_style = QStringLiteral(
/* Tree View (List View) Selection & Hover Style */
"QTreeView::item:hover {"
" background-color: %3;"
" border-radius: 4px;"
"}"
"QTreeView::item:selected {"
" background-color: %2;"
" color: palette(text);"
" border: none;"
" border-radius: 4px;"
"}"
"QTreeView::item:selected:!active {"
" background-color: palette(light);"
" border: none;"
"}"
/* List View (Grid View) Selection Style */
"QListView::item:selected {"
" background-color: palette(light);"
" border: 3px solid %1;"
" border-radius: 12px;"
"}"
"QListView::item:selected:!active {"
" background-color: transparent;"
" border: 3px solid palette(mid);"
"}"
/* ScrollBar Styling */
"QScrollBar:vertical {"
" border: 1px solid black;"
" background: palette(base);"
" width: 12px;"
" margin: 0px;"
"}"
"QScrollBar::handle:vertical {"
" background: %1;"
" min-height: 20px;"
" border-radius: 5px;"
" border: 1px solid black;"
"}"
).arg(color_name, selection_background_color_name, hover_background_color_name);
// Apply the combined base styles and new accent styles to each view
tree_view->setStyleSheet(QStringLiteral("QTreeView{ border: none; }") + accent_style);
list_view->setStyleSheet(QStringLiteral("QListView{ border: none; background: transparent; } QListView::item { text-align: center; padding: 5px; }") + accent_style);
// Update the toolbar buttons
QString button_base_style = QStringLiteral(
"QToolButton {"
" border: 1px solid palette(mid);"
" border-radius: 4px;"
" background: palette(button);"
"}"
"QToolButton:hover {"
" background: palette(light);"
"}"
);
QString button_checked_style = QStringLiteral(
"QToolButton:checked {"
" background: %1;"
" border-color: %1;"
"}"
).arg(color_name);
btn_list_view->setStyleSheet(button_base_style + button_checked_style);
btn_grid_view->setStyleSheet(button_base_style + button_checked_style);
}
void GameList::ToggleHidden(const QString& path) {
if (UISettings::values.hidden_paths.contains(path)) {
UISettings::values.hidden_paths.removeOne(path);
} else {
UISettings::values.hidden_paths.append(path);
}
// Refresh the current view to reflect the change
OnTextChanged(search_field->filterText());
emit SaveConfig();
}
void GameList::resizeEvent(QResizeEvent* event) {
QWidget::resizeEvent(event);
// Ensure the overlay always perfectly covers the game list widget
fade_overlay->setGeometry(rect());
}
void GameListPlaceholder::resizeEvent(QResizeEvent* event) {
QWidget::resizeEvent(event);
}
void GameList::OnEmulationEnded() {
// This function is called when the emulator returns to the game list.
// We now fade the black overlay back out.
auto* effect = new QGraphicsOpacityEffect(fade_overlay);
fade_overlay->setGraphicsEffect(effect);
auto* fade_out_anim = new QPropertyAnimation(effect, "opacity");
fade_out_anim->setDuration(300);
fade_out_anim->setStartValue(1.0);
fade_out_anim->setEndValue(0.0);
fade_out_anim->setEasingCurve(QEasingCurve::OutQuad);
// When the fade-out is complete, hide the overlay widget
connect(fade_out_anim, &QPropertyAnimation::finished, this, [this]() {
fade_overlay->hide();
});
fade_out_anim->start(QAbstractAnimation::DeleteWhenStopped);
}
#include "game_list.moc" #include "game_list.moc"

View File

@@ -17,6 +17,7 @@
#include <QProgressBar> #include <QProgressBar>
#include <QStandardItemModel> #include <QStandardItemModel>
#include <QString> #include <QString>
#include <QResizeEvent>
#include <QTimer> #include <QTimer>
#include <QToolButton> #include <QToolButton>
#include <QTreeView> #include <QTreeView>
@@ -148,9 +149,14 @@ signals:
public slots: public slots:
void OnConfigurationChanged(); void OnConfigurationChanged();
protected:
void resizeEvent(QResizeEvent* event) override;
private slots: private slots:
void OnEmulationEnded();
void onSurpriseMeClicked(); void onSurpriseMeClicked();
void UpdateProgressBarColor(); void UpdateProgressBarColor();
void UpdateAccentColorStyles();
void OnItemExpanded(const QModelIndex& item); void OnItemExpanded(const QModelIndex& item);
void OnTextChanged(const QString& new_text); void OnTextChanged(const QString& new_text);
void OnFilterCloseClicked(); void OnFilterCloseClicked();
@@ -175,6 +181,9 @@ private:
void AddFavorite(u64 program_id); void AddFavorite(u64 program_id);
void RemoveFavorite(u64 program_id); void RemoveFavorite(u64 program_id);
void StartLaunchAnimation(const QModelIndex& item);
void ToggleHidden(const QString& path);
void PopulateGridView(); void PopulateGridView();
void FilterGridView(const QString& filter_text); void FilterGridView(const QString& filter_text);
@@ -196,6 +205,7 @@ private:
GMainWindow* main_window = nullptr; GMainWindow* main_window = nullptr;
QVBoxLayout* layout = nullptr; QVBoxLayout* layout = nullptr;
QWidget* toolbar = nullptr; QWidget* toolbar = nullptr;
QWidget* fade_overlay;
QHBoxLayout* toolbar_layout = nullptr; QHBoxLayout* toolbar_layout = nullptr;
QToolButton* btn_list_view = nullptr; QToolButton* btn_list_view = nullptr;
QToolButton* btn_grid_view = nullptr; QToolButton* btn_grid_view = nullptr;
@@ -236,6 +246,7 @@ private slots:
void onUpdateThemedIcons(); void onUpdateThemedIcons();
protected: protected:
void resizeEvent(QResizeEvent* event) override;
void mouseDoubleClickEvent(QMouseEvent* event) override; void mouseDoubleClickEvent(QMouseEvent* event) override;
private: private:

View File

@@ -4266,6 +4266,7 @@ void GMainWindow::OnConfigure() {
UISettings::values.configuration_applied = false; UISettings::values.configuration_applied = false;
config->SaveAllValues(); config->SaveAllValues();
emit ConfigurationSaved();
if (Settings::values.mouse_panning && emulation_running) { if (Settings::values.mouse_panning && emulation_running) {
render_window->installEventFilter(render_window); render_window->installEventFilter(render_window);

View File

@@ -135,6 +135,7 @@ signals:
void WebBrowserExtractOfflineRomFS(); void WebBrowserExtractOfflineRomFS();
void WebBrowserClosed(Service::AM::Frontend::WebExitReason exit_reason, std::string last_url); void WebBrowserClosed(Service::AM::Frontend::WebExitReason exit_reason, std::string last_url);
void SigInterrupt(); void SigInterrupt();
void ConfigurationSaved();
public slots: public slots:
void OnLoadComplete(); void OnLoadComplete();
void OnExecuteProgram(std::size_t program_index); void OnExecuteProgram(std::size_t program_index);

View File

@@ -224,6 +224,7 @@ namespace UISettings {
Setting<bool> prompt_for_autoloader{linkage, true, "prompt_for_autoloader", Category::UiGameList}; Setting<bool> prompt_for_autoloader{linkage, true, "prompt_for_autoloader", Category::UiGameList};
Setting<bool> favorites_expanded{linkage, true, "favorites_expanded", Category::UiGameList}; Setting<bool> favorites_expanded{linkage, true, "favorites_expanded", Category::UiGameList};
QVector<u64> favorited_ids; QVector<u64> favorited_ids;
QStringList hidden_paths;
// Compatibility List // Compatibility List
Setting<bool> show_compat{linkage, false, "show_compat", Category::UiGameList}; Setting<bool> show_compat{linkage, false, "show_compat", Category::UiGameList};