diff --git a/src/duckstation-qt/CMakeLists.txt b/src/duckstation-qt/CMakeLists.txt index a1e1edfac..9e2fd8645 100644 --- a/src/duckstation-qt/CMakeLists.txt +++ b/src/duckstation-qt/CMakeLists.txt @@ -106,6 +106,9 @@ set(SRCS interfacesettingswidget.cpp interfacesettingswidget.h interfacesettingswidget.ui + isobrowserwindow.cpp + isobrowserwindow.h + isobrowserwindow.ui logwindow.cpp logwindow.h mainwindow.cpp diff --git a/src/duckstation-qt/duckstation-qt.vcxproj b/src/duckstation-qt/duckstation-qt.vcxproj index d29bacc47..f11917df0 100644 --- a/src/duckstation-qt/duckstation-qt.vcxproj +++ b/src/duckstation-qt/duckstation-qt.vcxproj @@ -27,6 +27,7 @@ + @@ -85,6 +86,7 @@ + @@ -242,6 +244,7 @@ + @@ -354,6 +357,9 @@ Document + + Document + diff --git a/src/duckstation-qt/duckstation-qt.vcxproj.filters b/src/duckstation-qt/duckstation-qt.vcxproj.filters index 220b25d9c..2e0b9024a 100644 --- a/src/duckstation-qt/duckstation-qt.vcxproj.filters +++ b/src/duckstation-qt/duckstation-qt.vcxproj.filters @@ -175,6 +175,10 @@ moc + + + moc + @@ -237,6 +241,7 @@ + @@ -289,6 +294,7 @@ + diff --git a/src/duckstation-qt/isobrowserwindow.cpp b/src/duckstation-qt/isobrowserwindow.cpp new file mode 100644 index 000000000..cb15c820c --- /dev/null +++ b/src/duckstation-qt/isobrowserwindow.cpp @@ -0,0 +1,340 @@ +// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin +// SPDX-License-Identifier: CC-BY-NC-ND-4.0 + +#include "isobrowserwindow.h" +#include "qtprogresscallback.h" +#include "qtutils.h" + +#include "util/cd_image.h" + +#include "common/align.h" +#include "common/error.h" +#include "common/file_system.h" +#include "common/log.h" +#include "common/path.h" + +#include +#include +#include +#include +#include + +LOG_CHANNEL(Host); + +ISOBrowserWindow::ISOBrowserWindow(QWidget* parent) : QWidget(parent) +{ + m_ui.setupUi(this); + m_ui.splitter->setSizes({200, 600}); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + + connect(m_ui.openFile, &QAbstractButton::clicked, this, &ISOBrowserWindow::onOpenFileClicked); + connect(m_ui.directoryView, &QTreeWidget::itemClicked, this, &ISOBrowserWindow::onDirectoryItemClicked); + connect(m_ui.fileView, &QTreeWidget::itemActivated, this, &ISOBrowserWindow::onFileItemActivated); + connect(m_ui.fileView, &QTreeWidget::itemSelectionChanged, this, &ISOBrowserWindow::onFileItemSelectionChanged); + connect(m_ui.fileView, &QTreeWidget::customContextMenuRequested, this, &ISOBrowserWindow::onFileContextMenuRequested); + connect(m_ui.close, &QAbstractButton::clicked, this, &ISOBrowserWindow::close); +} + +ISOBrowserWindow::~ISOBrowserWindow() = default; + +ISOBrowserWindow* ISOBrowserWindow::createAndOpenFile(QWidget* parent, const QString& path) +{ + ISOBrowserWindow* ib = new ISOBrowserWindow(nullptr); + + Error error; + if (!ib->tryOpenFile(path, &error)) + { + QMessageBox::critical(parent, tr("Error"), + tr("Failed to open %1:\n%2").arg(path).arg(QString::fromStdString(error.GetDescription()))); + delete ib; + return nullptr; + } + + return ib; +} + +bool ISOBrowserWindow::tryOpenFile(const QString& path, Error* error /*= nullptr*/) +{ + const std::string native_path = QDir::toNativeSeparators(path).toStdString(); + std::unique_ptr image = CDImage::Open(native_path.c_str(), false, error); + if (!image) + return false; + + IsoReader new_reader; + if (!new_reader.Open(image.get(), 1, error)) + return false; + + m_image = std::move(image); + m_iso = std::move(new_reader); + m_ui.openPath->setText(QString::fromStdString(native_path)); + setWindowTitle(tr("ISO Browser - %1").arg(QtUtils::StringViewToQString(Path::GetFileName(native_path)))); + populateDirectories(); + populateFiles(QString()); + return true; +} + +void ISOBrowserWindow::resizeEvent(QResizeEvent* ev) +{ + QWidget::resizeEvent(ev); + resizeFileListColumns(); +} + +void ISOBrowserWindow::showEvent(QShowEvent* ev) +{ + QWidget::showEvent(ev); + resizeFileListColumns(); +} + +void ISOBrowserWindow::onOpenFileClicked() +{ + const QString path = QFileDialog::getOpenFileName( + this, tr("Select File"), + m_image ? QtUtils::StringViewToQString(Path::GetDirectory(m_image->GetPath())) : QString()); + if (path.isEmpty()) + return; + + Error error; + if (!tryOpenFile(path, &error)) + { + QMessageBox::critical(this, tr("Error"), + tr("Failed to open %1:\n%2").arg(path).arg(QString::fromStdString(error.GetDescription()))); + return; + } +} + +void ISOBrowserWindow::onDirectoryItemClicked(QTreeWidgetItem* item, int column) +{ + populateFiles(item->data(0, Qt::UserRole).toString()); +} + +void ISOBrowserWindow::onFileItemActivated(QTreeWidgetItem* item, int column) +{ + if (item->data(0, Qt::UserRole + 1).toBool()) + { + // directory + const QString dir = item->data(0, Qt::UserRole).toString(); + populateFiles(dir); + + // select it in the directory list + QTreeWidgetItem* dir_item = findDirectoryItemForPath(dir, nullptr); + if (dir_item) + { + QSignalBlocker sb(m_ui.directoryView); + m_ui.directoryView->clearSelection(); + dir_item->setSelected(true); + } + + return; + } + + // file, go to extract + extractFile(item->data(0, Qt::UserRole).toString()); +} + +void ISOBrowserWindow::onFileItemSelectionChanged() +{ + const QList items = m_ui.fileView->selectedItems(); + if (items.isEmpty()) + { + m_ui.extract->setEnabled(false); + return; + } + + // directory? + const bool is_directory = items.front()->data(0, Qt::UserRole + 1).toBool(); + m_ui.extract->setEnabled(!is_directory); +} + +void ISOBrowserWindow::onFileContextMenuRequested(const QPoint& pos) +{ + const QList items = m_ui.fileView->selectedItems(); + if (items.isEmpty()) + return; + + QMenu menu; + + const bool is_directory = items.front()->data(0, Qt::UserRole + 1).toBool(); + const QString path = items.front()->data(0, Qt::UserRole).toString(); + if (is_directory) + { + connect(menu.addAction(QIcon::fromTheme(QIcon::ThemeIcon::FolderOpen), tr("&Open")), &QAction::triggered, this, + [this, &path]() { populateFiles(path); }); + } + else + { + connect(menu.addAction(QIcon::fromTheme(QIcon::ThemeIcon::DocumentSaveAs), tr("&Extract")), &QAction::triggered, + this, [this, &path]() { extractFile(path); }); + } + + menu.exec(m_ui.fileView->mapToGlobal(pos)); +} + +void ISOBrowserWindow::resizeFileListColumns() +{ + QtUtils::ResizeColumnsForTreeView(m_ui.fileView, {-1, 200, 100}); +} + +void ISOBrowserWindow::extractFile(const QString& path) +{ + const std::string spath = path.toStdString(); + const QString filename = QtUtils::StringViewToQString(Path::GetFileName(spath)); + std::string save_path = + QDir::toNativeSeparators(QFileDialog::getSaveFileName(this, tr("Extract File"), filename)).toStdString(); + if (save_path.empty()) + return; + + Error error; + std::optional de = m_iso.LocateFile(path.toStdString(), &error); + if (de.has_value()) + { + auto fp = FileSystem::CreateAtomicRenamedFile(std::move(save_path), &error); + if (fp) + { + QtModalProgressCallback cb(this, 0.15f); + cb.SetCancellable(true); + cb.SetTitle("ISO Browser"); + cb.SetStatusText(tr("Extracting %1...").arg(filename).toStdString()); + if (m_iso.WriteFileToStream(de.value(), fp.get(), &error, &cb)) + { + if (FileSystem::CommitAtomicRenamedFile(fp, &error)) + return; + } + else + { + // don't display error if cancelled + FileSystem::DiscardAtomicRenamedFile(fp); + if (cb.IsCancellable()) + return; + } + } + } + + QMessageBox::critical(this, tr("Error"), + tr("Failed to save %1:\n%2").arg(path).arg(QString::fromStdString(error.GetDescription()))); +} + +QTreeWidgetItem* ISOBrowserWindow::findDirectoryItemForPath(const QString& path, QTreeWidgetItem* parent) const +{ + if (!parent) + { + parent = m_ui.directoryView->topLevelItem(0); + if (path.isEmpty()) + return parent; + } + + const int count = parent->childCount(); + for (int i = 0; i < count; i++) + { + QTreeWidgetItem* item = parent->child(i); + if (item->data(0, Qt::UserRole) == path) + return item; + + QTreeWidgetItem* child_item = findDirectoryItemForPath(path, item); + if (child_item) + return child_item; + } + + return nullptr; +} + +void ISOBrowserWindow::populateDirectories() +{ + m_ui.directoryView->clear(); + m_ui.extract->setEnabled(false); + + QTreeWidgetItem* root = new QTreeWidgetItem; + root->setIcon(0, QIcon::fromTheme("disc-line")); + root->setText(0, QtUtils::StringViewToQString(Path::GetFileTitle(m_image->GetPath()))); + root->setData(0, Qt::UserRole, QString()); + m_ui.directoryView->addTopLevelItem(root); + + populateSubdirectories(std::string_view(), root); + + root->setExpanded(true); + + QSignalBlocker sb(m_ui.directoryView); + root->setSelected(true); +} + +void ISOBrowserWindow::populateSubdirectories(std::string_view dir, QTreeWidgetItem* parent) +{ + Error error; + std::vector> entries = m_iso.GetEntriesInDirectory(dir, &error); + if (entries.empty() && error.IsValid()) + { + ERROR_LOG("Failed to populate directory '{}': {}", dir, error.GetDescription()); + return; + } + + for (const auto& [full_path, entry] : entries) + { + if (!entry.IsDirectory()) + continue; + + QTreeWidgetItem* item = new QTreeWidgetItem(parent); + item->setIcon(0, QIcon::fromTheme(QStringLiteral("folder-open-line"))); + item->setText(0, QtUtils::StringViewToQString(Path::GetFileName(full_path))); + item->setData(0, Qt::UserRole, QString::fromStdString(full_path)); + populateSubdirectories(full_path, item); + } +} + +void ISOBrowserWindow::populateFiles(const QString& path) +{ + const std::string spath = path.toStdString(); + + m_ui.fileView->clear(); + + Error error; + std::vector> entries = + m_iso.GetEntriesInDirectory(spath, &error); + if (entries.empty() && error.IsValid()) + { + ERROR_LOG("Failed to populate files '{}': {}", spath, error.GetDescription()); + return; + } + + const auto add_entry = [this](const std::string& full_path, const IsoReader::ISODirectoryEntry& entry) { + const std::string_view filename = Path::GetFileName(full_path); + + QTreeWidgetItem* item = new QTreeWidgetItem; + item->setIcon( + 0, QIcon::fromTheme(entry.IsDirectory() ? QStringLiteral("folder-open-line") : QStringLiteral("file-line"))); + item->setText(0, QtUtils::StringViewToQString(Path::GetFileName(full_path))); + item->setData(0, Qt::UserRole, QString::fromStdString(full_path)); + item->setData(0, Qt::UserRole + 1, entry.IsDirectory()); + item->setText(1, QString::fromStdString(entry.recoding_time.GetFormattedTime())); + item->setText(2, tr("%1 KB").arg(Common::AlignUpPow2(entry.length_le, 1024) / 1024)); + m_ui.fileView->addTopLevelItem(item); + }; + + if (!path.isEmpty()) + { + QTreeWidgetItem* item = new QTreeWidgetItem; + item->setIcon(0, QIcon::fromTheme(QStringLiteral("folder-open-line"))); + item->setText(0, tr("")); + item->setData(0, Qt::UserRole, QtUtils::StringViewToQString(Path::GetDirectory(spath))); + item->setData(0, Qt::UserRole + 1, true); + m_ui.fileView->addTopLevelItem(item); + } + + // list directories first + for (const auto& [full_path, entry] : entries) + { + if (!entry.IsDirectory()) + continue; + + add_entry(full_path, entry); + } + + for (const auto& [full_path, entry] : entries) + { + if (entry.IsDirectory()) + continue; + + add_entry(full_path, entry); + } + + // this is utter shit, the scrollbar visibility doesn't update in time, so we have to queue it. + QTimer::singleShot(20, Qt::TimerType::CoarseTimer, this, SLOT(resizeFileListColumns())); +} diff --git a/src/duckstation-qt/isobrowserwindow.h b/src/duckstation-qt/isobrowserwindow.h new file mode 100644 index 000000000..6e67c8c03 --- /dev/null +++ b/src/duckstation-qt/isobrowserwindow.h @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin +// SPDX-License-Identifier: CC-BY-NC-ND-4.0 + +#pragma once + +#include "ui_isobrowserwindow.h" + +#include "util/iso_reader.h" + +class ISOBrowserWindow : public QWidget +{ + Q_OBJECT + +public: + ISOBrowserWindow(QWidget* parent = nullptr); + ~ISOBrowserWindow(); + + static ISOBrowserWindow* createAndOpenFile(QWidget* parent, const QString& path); + + bool tryOpenFile(const QString& path, Error* error = nullptr); + +protected: + void resizeEvent(QResizeEvent* ev); + void showEvent(QShowEvent* ev); + +private Q_SLOTS: + void onOpenFileClicked(); + void onDirectoryItemClicked(QTreeWidgetItem* item, int column); + void onFileItemActivated(QTreeWidgetItem* item, int column); + void onFileItemSelectionChanged(); + void onFileContextMenuRequested(const QPoint& pos); + void resizeFileListColumns(); + +private: + void populateDirectories(); + void populateSubdirectories(std::string_view dir, QTreeWidgetItem* parent); + void populateFiles(const QString& path); + void extractFile(const QString& path); + + QTreeWidgetItem* findDirectoryItemForPath(const QString& path, QTreeWidgetItem* parent = nullptr) const; + + Ui::ISOBrowserWindow m_ui; + std::unique_ptr m_image; + IsoReader m_iso; +}; diff --git a/src/duckstation-qt/isobrowserwindow.ui b/src/duckstation-qt/isobrowserwindow.ui new file mode 100644 index 000000000..5dd42e5fc --- /dev/null +++ b/src/duckstation-qt/isobrowserwindow.ui @@ -0,0 +1,132 @@ + + + ISOBrowserWindow + + + + 0 + 0 + 828 + 511 + + + + Form + + + + :/icons/media-optical.png:/icons/media-optical.png + + + + + + + + File: + + + + + + + true + + + + + + + ... + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + true + + + + Name + + + + + + Qt::ContextMenuPolicy::CustomContextMenu + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + false + + + + Name + + + + + Date + + + + + Size + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Extract + + + + + + + Close + + + + + + + + + + + + diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp index 44e349107..02aab3b49 100644 --- a/src/duckstation-qt/mainwindow.cpp +++ b/src/duckstation-qt/mainwindow.cpp @@ -12,6 +12,7 @@ #include "gamelistsettingswidget.h" #include "gamelistwidget.h" #include "interfacesettingswidget.h" +#include "isobrowserwindow.h" #include "logwindow.h" #include "memorycardeditorwindow.h" #include "memoryscannerwindow.h" @@ -1422,6 +1423,18 @@ void MainWindow::onGameListEntryContextMenuRequested(const QPoint& point) QtUtils::OpenURL(this, QUrl::fromLocalFile(fi.absolutePath())); }); + if (entry->IsDisc()) + { + connect(menu.addAction(tr("Browse ISO...")), &QAction::triggered, [this, entry]() { + ISOBrowserWindow* ib = ISOBrowserWindow::createAndOpenFile(this, QString::fromStdString(entry->path)); + if (ib) + { + ib->setAttribute(Qt::WA_DeleteOnClose); + ib->show(); + } + }); + } + connect(menu.addAction(tr("Set Cover Image...")), &QAction::triggered, [this, entry]() { setGameListEntryCoverImage(entry); });