#include "save_manager_dialog.h" #include "custom_table_widget_item.h" #include "qt_utils.h" #include "gui_settings.h" #include "persistent_settings.h" #include "game_list_delegate.h" #include "progress_dialog.h" #include "Emu/System.h" #include "Emu/system_utils.hpp" #include "Loader/PSF.h" #include #include #include #include #include #include #include #include #include #include #include #include "Utilities/File.h" #include "Utilities/mutex.h" LOG_CHANNEL(gui_log, "GUI"); namespace { // Helper converters constexpr auto qstr = QString::fromStdString; QString FormatTimestamp(s64 time) { QDateTime dateTime; dateTime.setSecsSinceEpoch(time); return dateTime.toString("yyyy-MM-dd HH:mm:ss"); } } enum SaveColumns { Icon = 0, Name = 1, Time = 2, Dir = 3, Note = 4, Count }; enum SaveUserRole { Pixmap = Qt::UserRole, PixmapLoaded }; save_manager_dialog::save_manager_dialog(std::shared_ptr gui_settings, std::shared_ptr persistent_settings, std::string dir, QWidget* parent) : QDialog(parent) , m_dir(dir) , m_gui_settings(std::move(gui_settings)) , m_persistent_settings(std::move(persistent_settings)) { setWindowTitle(tr("Save Manager")); setMinimumSize(QSize(400, 400)); setAttribute(Qt::WA_DeleteOnClose); Init(); } /* * Future proofing. Makes it easier in future if I add ability to change directories */ void save_manager_dialog::Init() { // Table m_list = new QTableWidget(this); m_list->setItemDelegate(new game_list_delegate(m_list)); m_list->setSelectionMode(QAbstractItemView::SelectionMode::ExtendedSelection); m_list->setSelectionBehavior(QAbstractItemView::SelectRows); m_list->setContextMenuPolicy(Qt::CustomContextMenu); m_list->setColumnCount(SaveColumns::Count); m_list->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); m_list->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel); m_list->verticalScrollBar()->setSingleStep(20); m_list->horizontalScrollBar()->setSingleStep(10); m_list->setHorizontalHeaderLabels(QStringList() << tr("Icon") << tr("Title & Subtitle") << tr("Last Modified") << tr("Save ID") << tr("Notes")); m_list->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Fixed); m_list->horizontalHeader()->setStretchLastSection(true); // Bottom bar const int icon_size = m_gui_settings->GetValue(gui::sd_icon_size).toInt(); m_icon_size = QSize(icon_size, icon_size * 176 / 320); QLabel* label_icon_size = new QLabel(tr("Icon size:"), this); QSlider* slider_icon_size = new QSlider(Qt::Horizontal, this); slider_icon_size->setMinimum(60); slider_icon_size->setMaximum(225); slider_icon_size->setValue(icon_size); QPushButton* push_close = new QPushButton(tr("&Close"), this); push_close->setAutoDefault(true); // Details m_details_icon = new QLabel(this); m_details_icon->setMinimumSize(320, 176); m_details_title = new QLabel(tr("Select an item to view details"), this); m_details_title->setWordWrap(true); m_details_subtitle = new QLabel(this); m_details_subtitle->setWordWrap(true); m_details_modified = new QLabel(this); m_details_modified->setWordWrap(true); m_details_details = new QLabel(this); m_details_details->setWordWrap(true); m_details_note = new QLabel(this); m_details_note->setWordWrap(true); m_button_delete = new QPushButton(tr("Delete Selection"), this); m_button_delete->setDisabled(true); m_button_folder = new QPushButton(tr("View Folder"), this); m_button_delete->setDisabled(true); // Details layout QVBoxLayout *vbox_details = new QVBoxLayout(); vbox_details->addWidget(m_details_icon); vbox_details->addWidget(m_details_title); vbox_details->addWidget(m_details_subtitle); vbox_details->addWidget(m_details_modified); vbox_details->addWidget(m_details_details); vbox_details->addWidget(m_details_note); vbox_details->addStretch(); vbox_details->addWidget(m_button_delete); vbox_details->setAlignment(m_button_delete, Qt::AlignHCenter); vbox_details->addWidget(m_button_folder); vbox_details->setAlignment(m_button_folder, Qt::AlignHCenter); // List + Details QHBoxLayout *hbox_content = new QHBoxLayout(); hbox_content->addWidget(m_list); hbox_content->addLayout(vbox_details); // Items below list QHBoxLayout* hbox_buttons = new QHBoxLayout(); hbox_buttons->addWidget(label_icon_size); hbox_buttons->addWidget(slider_icon_size); hbox_buttons->addStretch(); hbox_buttons->addWidget(push_close); // main layout QVBoxLayout* vbox_main = new QVBoxLayout(); vbox_main->setAlignment(Qt::AlignCenter); vbox_main->addLayout(hbox_content); vbox_main->addLayout(hbox_buttons); setLayout(vbox_main); UpdateList(); m_list->sortByColumn(1, Qt::AscendingOrder); if (restoreGeometry(m_gui_settings->GetValue(gui::sd_geometry).toByteArray())) resize(size().expandedTo(QGuiApplication::primaryScreen()->availableSize() * 0.5)); // Connects and events connect(push_close, &QAbstractButton::clicked, this, &save_manager_dialog::close); connect(m_button_delete, &QAbstractButton::clicked, this, &save_manager_dialog::OnEntriesRemove); connect(m_button_folder, &QAbstractButton::clicked, [this]() { const int idx = m_list->currentRow(); QTableWidgetItem* item = m_list->item(idx, SaveColumns::Name); if (!item) { return; } const int idx_real = item->data(Qt::UserRole).toInt(); const QString path = qstr(m_dir + ::at32(m_save_entries, idx_real).dirName + "/"); gui::utils::open_dir(path); }); connect(slider_icon_size, &QAbstractSlider::valueChanged, this, &save_manager_dialog::SetIconSize); connect(m_list->horizontalHeader(), &QHeaderView::sectionClicked, this, &save_manager_dialog::OnSort); connect(m_list, &QTableWidget::customContextMenuRequested, this, &save_manager_dialog::ShowContextMenu); connect(m_list, &QTableWidget::cellChanged, [&](int row, int col) { if (col != SaveColumns::Note) { return; } QTableWidgetItem* user_item = m_list->item(row, SaveColumns::Name); QTableWidgetItem* text_item = m_list->item(row, SaveColumns::Note); if (!user_item || !text_item) { return; } const int original_index = user_item->data(Qt::UserRole).toInt(); const SaveDataEntry originalEntry = ::at32(m_save_entries, original_index); const QString original_dir_name = qstr(originalEntry.dirName); QVariantMap notes = m_persistent_settings->GetValue(gui::persistent::save_notes).toMap(); notes[original_dir_name] = text_item->text(); m_persistent_settings->SetValue(gui::persistent::save_notes, notes); }); connect(m_list, &QTableWidget::itemSelectionChanged, this, &save_manager_dialog::UpdateDetails); connect(this, &save_manager_dialog::IconReady, this, [this](int index, const QPixmap& pixmap) { if (QTableWidgetItem* icon_item = m_list->item(index, SaveColumns::Icon)) { icon_item->setData(Qt::DecorationRole, pixmap); } }); } /** * This certainly isn't ideal for this code, as it essentially copies cellSaveData. But, I have no other choice without adding public methods to cellSaveData. */ std::vector save_manager_dialog::GetSaveEntries(const std::string& base_dir) { std::vector save_entries; std::vector dir_list; qRegisterMetaType>("QVector"); QList indices; for (const auto& entry : fs::dir(base_dir)) { if (!entry.is_directory || entry.name == "." || entry.name == "..") { continue; } indices.append(static_cast(dir_list.size())); dir_list.emplace_back(entry); } if (dir_list.empty()) { return save_entries; } QFutureWatcher future_watcher; progress_dialog progress_dialog(tr("Loading save data"), tr("Loading save data, please wait..."), tr("Cancel"), 0, 1, false, this, Qt::Dialog | Qt::WindowTitleHint | Qt::CustomizeWindowHint); connect(&future_watcher, &QFutureWatcher::progressRangeChanged, &progress_dialog, &QProgressDialog::setRange); connect(&future_watcher, &QFutureWatcher::progressValueChanged, &progress_dialog, &QProgressDialog::setValue); connect(&progress_dialog, &QProgressDialog::canceled, this, [this, &future_watcher]() { future_watcher.cancel(); close(); // It's pointless to show an empty window }); shared_mutex mutex; future_watcher.setFuture(QtConcurrent::map(indices, [&](int index) { const fs::dir_entry& entry = ::at32(dir_list, index); gui_log.trace("Loading trophy dir: %s", entry.name); // PSF parameters const auto [psf, errc] = psf::load(base_dir + entry.name + "/PARAM.SFO"); if (psf.empty()) { gui_log.error("Failed to load savedata: %s (%s)", base_dir + "/" + entry.name, errc); return; } SaveDataEntry save_entry2; save_entry2.dirName = psf::get_string(psf, "SAVEDATA_DIRECTORY"); save_entry2.listParam = psf::get_string(psf, "SAVEDATA_LIST_PARAM"); save_entry2.title = psf::get_string(psf, "TITLE"); save_entry2.subtitle = psf::get_string(psf, "SUB_TITLE"); save_entry2.details = psf::get_string(psf, "DETAIL"); save_entry2.size = 0; for (const auto& entry2 : fs::dir(base_dir + entry.name)) { save_entry2.size += entry2.size; } save_entry2.atime = entry.atime; save_entry2.mtime = entry.mtime; save_entry2.ctime = entry.ctime; save_entry2.isNew = false; std::scoped_lock lock(mutex); save_entries.emplace_back(save_entry2); })); progress_dialog.exec(); future_watcher.waitForFinished(); return save_entries; } void save_manager_dialog::UpdateList() { WaitForRepaintThreads(true); if (m_dir.empty()) { m_dir = rpcs3::utils::get_hdd0_dir() + "home/" + Emu.GetUsr() + "/savedata/"; } m_save_entries = GetSaveEntries(m_dir); m_list->setSortingEnabled(false); // Disable sorting before using setItem calls m_list->clearContents(); m_list->setRowCount(static_cast(m_save_entries.size())); const QVariantMap notes = m_persistent_settings->GetValue(gui::persistent::save_notes).toMap(); if (m_gui_settings->GetValue(gui::m_enableUIColors).toBool()) { m_icon_color = m_gui_settings->GetValue(gui::sd_icon_color).value(); } else { m_icon_color = gui::utils::get_label_color("save_manager_icon_background_color"); } QPixmap placeholder(320, 176); placeholder.fill(Qt::transparent); for (int i = 0; i < static_cast(m_save_entries.size()); ++i) { const SaveDataEntry& entry = ::at32(m_save_entries, i); const QString title = qstr(entry.title) + QStringLiteral("\n") + qstr(entry.subtitle); const QString dir_name = qstr(entry.dirName); custom_table_widget_item* iconItem = new custom_table_widget_item; iconItem->setData(Qt::DecorationRole, placeholder); iconItem->setData(SaveUserRole::Pixmap, placeholder); iconItem->setData(SaveUserRole::PixmapLoaded, false); iconItem->setFlags(iconItem->flags() & ~Qt::ItemIsEditable); m_list->setItem(i, SaveColumns::Icon, iconItem); QTableWidgetItem* titleItem = new QTableWidgetItem(title); titleItem->setData(Qt::UserRole, i); // For sorting to work properly titleItem->setFlags(titleItem->flags() & ~Qt::ItemIsEditable); m_list->setItem(i, SaveColumns::Name, titleItem); QTableWidgetItem* timeItem = new QTableWidgetItem(FormatTimestamp(entry.mtime)); timeItem->setFlags(timeItem->flags() & ~Qt::ItemIsEditable); m_list->setItem(i, SaveColumns::Time, timeItem); QTableWidgetItem* dirNameItem = new QTableWidgetItem(dir_name); dirNameItem->setFlags(dirNameItem->flags() & ~Qt::ItemIsEditable); m_list->setItem(i, SaveColumns::Dir, dirNameItem); QTableWidgetItem* noteItem = new QTableWidgetItem(); noteItem->setFlags(noteItem->flags() | Qt::ItemIsEditable); if (notes.contains(dir_name)) { noteItem->setText(notes[dir_name].toString()); } m_list->setItem(i, SaveColumns::Note, noteItem); } m_list->setSortingEnabled(true); // Enable sorting only after using setItem calls m_list->horizontalHeader()->resizeSections(QHeaderView::ResizeToContents); m_list->verticalHeader()->resizeSections(QHeaderView::ResizeToContents); const QSize tableSize( m_list->verticalHeader()->width() + m_list->horizontalHeader()->length() + m_list->frameWidth() * 2, m_list->horizontalHeader()->height() + m_list->verticalHeader()->length() + m_list->frameWidth() * 2); const QSize preferredSize = minimumSize().expandedTo(sizeHint() - m_list->sizeHint() + tableSize); const QSize maxSize(preferredSize.width(), static_cast(QGuiApplication::primaryScreen()->geometry().height() * 0.6)); resize(preferredSize.boundedTo(maxSize)); UpdateIcons(); } void save_manager_dialog::HandleRepaintUiRequest() { const QSize window_size = size(); const Qt::SortOrder sort_order = m_sort_ascending ? Qt::AscendingOrder : Qt::DescendingOrder; UpdateList(); m_list->sortByColumn(m_sort_column, sort_order); resize(window_size); } void save_manager_dialog::UpdateIcons() { WaitForRepaintThreads(true); const qreal dpr = devicePixelRatioF(); QPixmap placeholder(m_icon_size); placeholder.fill(Qt::transparent); for (int i = 0; i < m_list->rowCount(); ++i) { if (movie_item* icon_item = static_cast(m_list->item(i, SaveColumns::Icon))) { icon_item->setData(Qt::DecorationRole, placeholder); } } m_list->resizeRowsToContents(); m_list->resizeColumnToContents(SaveColumns::Icon); for (int i = 0; i < m_list->rowCount(); ++i) { if (movie_item* icon_item = static_cast(m_list->item(i, SaveColumns::Icon))) { icon_item->set_icon_load_func([this, cancel = icon_item->icon_loading_aborted(), dpr](int index) { if (cancel && cancel->load()) { return; } QPixmap icon; if (movie_item* item = static_cast(m_list->item(index, SaveColumns::Icon))) { if (!item->data(SaveUserRole::PixmapLoaded).toBool()) { // Load game icon if (QTableWidgetItem* user_item = m_list->item(index, SaveColumns::Name)) { const int idx_real = user_item->data(Qt::UserRole).toInt(); const SaveDataEntry& entry = ::at32(m_save_entries, idx_real); if (!icon.load(QString::fromStdString(m_dir + entry.dirName + "/ICON0.PNG"))) { gui_log.warning("Loading icon for save %s failed", entry.dirName); icon = QPixmap(320, 176); icon.fill(m_icon_color); } item->setData(SaveUserRole::PixmapLoaded, true); item->setData(SaveUserRole::Pixmap, icon); } else { gui_log.error("Loading icon for save failed (table item is null)"); } } else { icon = item->data(SaveUserRole::Pixmap).value(); } } if (cancel && cancel->load()) { return; } QPixmap new_icon(icon.size() * dpr); new_icon.setDevicePixelRatio(dpr); new_icon.fill(m_icon_color); if (!icon.isNull()) { QPainter painter(&new_icon); painter.setRenderHint(QPainter::SmoothPixmapTransform); painter.drawPixmap(QPoint(0, 0), icon); painter.end(); } new_icon = new_icon.scaled(m_icon_size * dpr, Qt::KeepAspectRatio, Qt::TransformationMode::SmoothTransformation); if (!cancel || !cancel->load()) { Q_EMIT IconReady(index, new_icon); } }); } } } /** * Copied method to do sort from save_data_list_dialog */ void save_manager_dialog::OnSort(int logicalIndex) { if (logicalIndex >= 0) { WaitForRepaintThreads(false); if (logicalIndex == m_sort_column) { m_sort_ascending ^= true; } else { m_sort_ascending = true; } m_sort_column = logicalIndex; const Qt::SortOrder sort_order = m_sort_ascending ? Qt::AscendingOrder : Qt::DescendingOrder; m_list->sortByColumn(m_sort_column, sort_order); } } // Remove a save file, need to be confirmed. void save_manager_dialog::OnEntryRemove(int row, bool user_interaction) { if (QTableWidgetItem* item = m_list->item(row, SaveColumns::Name)) { const int idx_real = item->data(Qt::UserRole).toInt(); const SaveDataEntry& entry = ::at32(m_save_entries, idx_real); if (!user_interaction || QMessageBox::question(this, tr("Delete Confirmation"), tr("Are you sure you want to delete:\n%1?").arg(qstr(entry.title)), QMessageBox::Yes, QMessageBox::No) == QMessageBox::Yes) { fs::remove_all(m_dir + entry.dirName + "/"); m_list->removeRow(row); } } } void save_manager_dialog::OnEntriesRemove() { QModelIndexList selection(m_list->selectionModel()->selectedRows()); if (selection.empty()) { return; } WaitForRepaintThreads(false); if (selection.size() == 1) { OnEntryRemove(selection.first().row(), true); return; } if (QMessageBox::question(this, tr("Delete Confirmation"), tr("Are you sure you want to delete these %n items?", "", selection.size()), QMessageBox::Yes, QMessageBox::No) == QMessageBox::Yes) { std::sort(selection.rbegin(), selection.rend()); for (const QModelIndex& index : selection) { OnEntryRemove(index.row(), false); } } } // Pop-up a small context-menu, being a replacement for save_data_manage_dialog void save_manager_dialog::ShowContextMenu(const QPoint &pos) { const int idx = m_list->currentRow(); if (idx == -1) { return; } WaitForRepaintThreads(false); const bool selectedItems = m_list->selectionModel()->selectedRows().size() > 1; QAction* removeAct = new QAction(tr("&Remove"), this); QAction* showDirAct = new QAction(tr("&Open Save Directory"), this); QMenu* menu = new QMenu(); menu->addAction(removeAct); menu->addAction(showDirAct); showDirAct->setEnabled(!selectedItems); removeAct->setEnabled(idx != -1); // Events connect(removeAct, &QAction::triggered, this, &save_manager_dialog::OnEntriesRemove); // entriesremove handles case of one as well connect(showDirAct, &QAction::triggered, [=, this]() { QTableWidgetItem* item = m_list->item(idx, SaveColumns::Name); if (!item) { return; } const int idx_real = item->data(Qt::UserRole).toInt(); const QString path = qstr(m_dir + ::at32(m_save_entries, idx_real).dirName + "/"); gui::utils::open_dir(path); }); menu->exec(m_list->viewport()->mapToGlobal(pos)); } void save_manager_dialog::SetIconSize(int size) { m_icon_size = QSize(size, size * 176 / 320); UpdateIcons(); m_gui_settings->SetValue(gui::sd_icon_size, size); } void save_manager_dialog::closeEvent(QCloseEvent *event) { m_gui_settings->SetValue(gui::sd_geometry, saveGeometry()); m_gui_settings->sync(); QDialog::closeEvent(event); } void save_manager_dialog::UpdateDetails() { if (const int selected = m_list->selectionModel()->selectedRows().size(); selected != 1) { m_details_icon->setPixmap(QPixmap()); m_details_subtitle->setText(""); m_details_modified->setText(""); m_details_details->setText(""); m_details_note->setText(""); if (selected > 1) { m_button_delete->setDisabled(false); m_details_title->setText(tr("%1 items selected").arg(selected)); } else { m_button_delete->setDisabled(true); m_details_title->setText(tr("Select an item to view details")); } m_button_folder->setDisabled(true); } else { WaitForRepaintThreads(false); const int row = m_list->currentRow(); QTableWidgetItem* item = m_list->item(row, SaveColumns::Name); QTableWidgetItem* icon_item = m_list->item(row, SaveColumns::Icon); if (!item || !icon_item) { return; } const int idx = item->data(Qt::UserRole).toInt(); const SaveDataEntry& save = ::at32(m_save_entries, idx); m_details_icon->setPixmap(icon_item->data(Qt::UserRole).value()); m_details_title->setText(qstr(save.title)); m_details_subtitle->setText(qstr(save.subtitle)); m_details_modified->setText(tr("Last modified: %1").arg(FormatTimestamp(save.mtime))); m_details_details->setText(tr("Details:\n").append(qstr(save.details))); QString note = tr("Note:\n"); if (const QVariantMap map = m_persistent_settings->GetValue(gui::persistent::save_notes).toMap(); map.contains(qstr(save.dirName))) { note.append(map[qstr(save.dirName)].toString()); } m_details_note->setText(note); m_button_delete->setDisabled(false); m_button_folder->setDisabled(false); } } void save_manager_dialog::WaitForRepaintThreads(bool abort) { for (int i = 0; i < m_list->rowCount(); i++) { if (movie_item* item = static_cast(m_list->item(i, SaveColumns::Icon))) { item->wait_for_icon_loading(abort); } } }