// Copyright 2015 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #ifdef _WIN32 #include #include #include // This file uses some identifiers which are defined as macros in Windows headers. // Include and undefine the macros first thing we do to solve build errors. #ifdef DeleteFile #undef DeleteFile #endif #ifdef interface #undef interface #endif #endif #include "DolphinQt/GameList/GameList.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #include "Common/Contains.h" #endif #include "Common/FileUtil.h" #include "Core/Config/MainSettings.h" #include "Core/Core.h" #include "Core/HW/DVD/DVDInterface.h" #include "Core/HW/EXI/EXI.h" #include "Core/HW/EXI/EXI_Device.h" #include "Core/HW/WiiSave.h" #include "Core/System.h" #include "Core/WiiUtils.h" #include "DiscIO/Enums.h" #include "DolphinQt/Config/PropertiesDialog.h" #include "DolphinQt/ConvertDialog.h" #include "DolphinQt/GameList/GridProxyModel.h" #include "DolphinQt/GameList/ListProxyModel.h" #include "DolphinQt/MenuBar.h" #include "DolphinQt/QtUtils/DolphinFileDialog.h" #include "DolphinQt/QtUtils/DoubleClickEventFilter.h" #include "DolphinQt/QtUtils/ModalMessageBox.h" #include "DolphinQt/QtUtils/NonAutodismissibleMenu.h" #include "DolphinQt/QtUtils/QtUtils.h" #include "DolphinQt/Resources.h" #include "DolphinQt/Settings.h" #include "DolphinQt/WiiUpdate.h" #include "UICommon/GameFile.h" namespace { class GameListTableView : public QTableView { public: explicit GameListTableView(QWidget* parent = nullptr) : QTableView(parent) {} protected: QModelIndex moveCursor(CursorAction cursorAction, Qt::KeyboardModifiers modifiers) override { // QTableView::moveCursor handles home by moving to the first column and end by moving to the // last column, unless control is held in which case it ALSO moves to the first/last row. // Columns are irrelevant for the game list, so treat the home/end press as if control were // held. if (cursorAction == CursorAction::MoveHome || cursorAction == CursorAction::MoveEnd) return QTableView::moveCursor(cursorAction, modifiers | Qt::ControlModifier); else return QTableView::moveCursor(cursorAction, modifiers); } virtual void mouseDoubleClickEvent(QMouseEvent* const event) override { if (event->button() == Qt::LeftButton) QTableView::mouseDoubleClickEvent(event); } }; class GameListListView : public QListView { public: explicit GameListListView(QWidget* parent = nullptr) : QListView(parent) {} protected: virtual void mouseDoubleClickEvent(QMouseEvent* const event) override { if (event->button() == Qt::LeftButton) QListView::mouseDoubleClickEvent(event); } }; } // namespace GameList::GameList(QWidget* parent) : QStackedWidget(parent), m_model(this) { m_list_proxy = new ListProxyModel(this); m_list_proxy->setSortCaseSensitivity(Qt::CaseInsensitive); m_list_proxy->setSortRole(GameListModel::SORT_ROLE); m_list_proxy->setSourceModel(&m_model); m_grid_proxy = new GridProxyModel(this); m_grid_proxy->setSortCaseSensitivity(Qt::CaseInsensitive); m_grid_proxy->setSortRole(GameListModel::SORT_ROLE); m_grid_proxy->setSourceModel(&m_model); MakeListView(); MakeGridView(); MakeEmptyView(); // Use List View's sorting for Grid View too. m_grid_proxy->sort(m_list_proxy->sortColumn(), m_list_proxy->sortOrder()); connect(m_list->horizontalHeader(), &QHeaderView::sortIndicatorChanged, m_grid_proxy, &GridProxyModel::sort); if (Settings::GetQSettings().contains(QStringLiteral("gridview/scale"))) m_model.SetScale(Settings::GetQSettings().value(QStringLiteral("gridview/scale")).toFloat()); connect(m_list, &QTableView::doubleClicked, this, &GameList::GameSelected); connect(m_grid, &QListView::doubleClicked, this, &GameList::GameSelected); connect(&m_model, &QAbstractItemModel::rowsInserted, this, &GameList::ConsiderViewChange); connect(&m_model, &QAbstractItemModel::rowsRemoved, this, &GameList::ConsiderViewChange); connect(&m_model, &QAbstractItemModel::rowsInserted, this, &GameList::UpdateGameCount); connect(&m_model, &QAbstractItemModel::rowsRemoved, this, &GameList::UpdateGameCount); addWidget(m_list); addWidget(m_grid); addWidget(m_empty); m_prefer_list = Settings::Instance().GetPreferredView(); ConsiderViewChange(); auto* zoom_in = new QShortcut(QKeySequence::ZoomIn, this); auto* zoom_out = new QShortcut(QKeySequence::ZoomOut, this); zoom_in->setContext(Qt::WidgetWithChildrenShortcut); zoom_out->setContext(Qt::WidgetWithChildrenShortcut); connect(zoom_in, &QShortcut::activated, this, &GameList::ZoomIn); connect(zoom_out, &QShortcut::activated, this, &GameList::ZoomOut); // On most keyboards the key to the left of the primary delete key represents 'plus' when shift is // held and 'equal' when it isn't. By common convention, pressing control and that key is treated // conceptually as 'control plus' (which is then interpreted as an appropriate zooming action) // instead of the technically correct 'control equal'. Qt doesn't account for this convention so // an alternate shortcut is needed to avoid counterintuitive behavior. const auto* zoom_in_alternate = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Equal), this); connect(zoom_in_alternate, &QShortcut::activated, this, &GameList::ZoomIn); // The above correction introduces a different inconsistency: now zooming in can be done using // conceptual 'control plus' or 'control shift plus', while zooming out can only be done using // 'control minus'. Adding an alternate shortcut representing 'control shift minus' restores // consistency. const auto* zoom_out_alternate = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Underscore), this); connect(zoom_out_alternate, &QShortcut::activated, this, &GameList::ZoomOut); connect(&Settings::Instance(), &Settings::MetadataRefreshCompleted, this, [this] { m_grid_proxy->invalidate(); }); } void GameList::PurgeCache() { m_model.PurgeCache(); } void GameList::MakeListView() { m_list = new GameListTableView(this); m_list->setModel(m_list_proxy); m_list->setTabKeyNavigation(false); m_list->setSelectionMode(QAbstractItemView::ExtendedSelection); m_list->setSelectionBehavior(QAbstractItemView::SelectRows); m_list->setAlternatingRowColors(true); m_list->setShowGrid(false); m_list->setSortingEnabled(true); m_list->setCurrentIndex(QModelIndex()); m_list->setContextMenuPolicy(Qt::CustomContextMenu); m_list->setWordWrap(false); // Have 1 pixel of padding above and below the 32 pixel banners. m_list->verticalHeader()->setDefaultSectionSize(32 + 2); QHeaderView* hor_header = m_list->horizontalHeader(); hor_header->restoreState( Settings::GetQSettings().value(QStringLiteral("tableheader/state")).toByteArray()); hor_header->setContextMenuPolicy(Qt::CustomContextMenu); connect(hor_header, &QWidget::customContextMenuRequested, this, &GameList::ShowHeaderContextMenu); connect(hor_header, &QHeaderView::sortIndicatorChanged, this, &GameList::OnHeaderViewChanged); connect(hor_header, &QHeaderView::sectionCountChanged, this, &GameList::OnHeaderViewChanged); connect(hor_header, &QHeaderView::sectionMoved, this, &GameList::OnHeaderViewChanged); connect(hor_header, &QHeaderView::sectionResized, this, &GameList::OnSectionResized); if (!Settings::GetQSettings().contains(QStringLiteral("tableheader/state"))) m_list->sortByColumn(static_cast(GameListModel::Column::Title), Qt::AscendingOrder); { const auto SetResizeMode = [&hor_header](const GameListModel::Column column, const QHeaderView::ResizeMode mode) { hor_header->setSectionResizeMode(static_cast(column), mode); }; using Column = GameListModel::Column; using Mode = QHeaderView::ResizeMode; SetResizeMode(Column::Platform, Mode::Fixed); SetResizeMode(Column::Banner, Mode::Fixed); SetResizeMode(Column::Title, Mode::Interactive); SetResizeMode(Column::Description, Mode::Interactive); SetResizeMode(Column::Maker, Mode::Interactive); SetResizeMode(Column::ID, Mode::Fixed); SetResizeMode(Column::Country, Mode::Fixed); SetResizeMode(Column::Size, Mode::Fixed); SetResizeMode(Column::FileName, Mode::Interactive); SetResizeMode(Column::FilePath, Mode::Interactive); SetResizeMode(Column::FileFormat, Mode::Fixed); SetResizeMode(Column::BlockSize, Mode::Fixed); SetResizeMode(Column::Compression, Mode::Fixed); SetResizeMode(Column::TimePlayed, Mode::Interactive); SetResizeMode(Column::Tags, Mode::Interactive); // Cells have 3 pixels of padding, so the width of these needs to be image width + 6. Banners // are 96 pixels wide, platform and country icons are 32 pixels wide. m_list->setColumnWidth(static_cast(Column::Banner), 102); m_list->setColumnWidth(static_cast(Column::Platform), 38); m_list->setColumnWidth(static_cast(Column::Country), 38); m_list->setColumnWidth(static_cast(Column::Size), 85); m_list->setColumnWidth(static_cast(Column::ID), 70); } // There's some odd platform-specific behavior with default minimum section size hor_header->setMinimumSectionSize(38); UpdateColumnVisibility(); m_list->verticalHeader()->hide(); m_list->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_list->setFrameStyle(QFrame::NoFrame); hor_header->setSectionsMovable(true); hor_header->setHighlightSections(false); // Work around a Qt bug where clicking in the background (below the last game) as the first // action and then pressing a key (e.g. page down or end) selects the first entry in the list // instead of performing that key's action. This workaround does not work if there are no games // when the view first appears, but then games are added (e.g. due to no game folders being // present, and then the user adding one), but that is an infrequent situation. m_list->selectRow(0); m_list->clearSelection(); connect(m_list, &QTableView::customContextMenuRequested, this, &GameList::ShowContextMenu); connect(m_list->selectionModel(), &QItemSelectionModel::selectionChanged, [this](const QItemSelection&, const QItemSelection&) { emit SelectionChanged(GetSelectedGame()); }); } GameList::~GameList() { Settings::GetQSettings().setValue(QStringLiteral("tableheader/state"), m_list->horizontalHeader()->saveState()); Settings::GetQSettings().setValue(QStringLiteral("gridview/scale"), m_model.GetScale()); } void GameList::UpdateColumnVisibility() { const auto SetVisiblity = [this](const GameListModel::Column column, const bool is_visible) { m_list->setColumnHidden(static_cast(column), !is_visible); }; using Column = GameListModel::Column; SetVisiblity(Column::Platform, Config::Get(Config::MAIN_GAMELIST_COLUMN_PLATFORM)); SetVisiblity(Column::Banner, Config::Get(Config::MAIN_GAMELIST_COLUMN_BANNER)); SetVisiblity(Column::Title, Config::Get(Config::MAIN_GAMELIST_COLUMN_TITLE)); SetVisiblity(Column::Description, Config::Get(Config::MAIN_GAMELIST_COLUMN_DESCRIPTION)); SetVisiblity(Column::Maker, Config::Get(Config::MAIN_GAMELIST_COLUMN_MAKER)); SetVisiblity(Column::ID, Config::Get(Config::MAIN_GAMELIST_COLUMN_GAME_ID)); SetVisiblity(Column::Country, Config::Get(Config::MAIN_GAMELIST_COLUMN_REGION)); SetVisiblity(Column::Size, Config::Get(Config::MAIN_GAMELIST_COLUMN_FILE_SIZE)); SetVisiblity(Column::FileName, Config::Get(Config::MAIN_GAMELIST_COLUMN_FILE_NAME)); SetVisiblity(Column::FilePath, Config::Get(Config::MAIN_GAMELIST_COLUMN_FILE_PATH)); SetVisiblity(Column::FileFormat, Config::Get(Config::MAIN_GAMELIST_COLUMN_FILE_FORMAT)); SetVisiblity(Column::BlockSize, Config::Get(Config::MAIN_GAMELIST_COLUMN_BLOCK_SIZE)); SetVisiblity(Column::Compression, Config::Get(Config::MAIN_GAMELIST_COLUMN_COMPRESSION)); SetVisiblity(Column::TimePlayed, Config::Get(Config::MAIN_GAMELIST_COLUMN_TIME_PLAYED)); SetVisiblity(Column::Tags, Config::Get(Config::MAIN_GAMELIST_COLUMN_TAGS)); } void GameList::MakeEmptyView() { const QString refreshing_msg = tr("Refreshing..."); const QString empty_msg = tr("Dolphin could not find any GameCube/Wii ISOs or WADs.\n" "Double-click here to set a games directory..."); m_empty = new QLabel(this); m_empty->setText(refreshing_msg); m_empty->setEnabled(false); m_empty->setAlignment(Qt::AlignHCenter | Qt::AlignVCenter); auto event_filter = new DoubleClickEventFilter{m_empty}; m_empty->installEventFilter(event_filter); connect(event_filter, &DoubleClickEventFilter::doubleClicked, [this] { auto current_dir = QDir::currentPath(); auto dir = DolphinFileDialog::getExistingDirectory(this, tr("Select a Directory"), current_dir); if (!dir.isEmpty()) Settings::Instance().AddPath(dir); }); QSizePolicy size_policy{m_empty->sizePolicy()}; size_policy.setRetainSizeWhenHidden(true); m_empty->setSizePolicy(size_policy); connect(&Settings::Instance(), &Settings::GameListRefreshRequested, this, [this, refreshing_msg] { m_empty->setText(refreshing_msg); m_empty->setEnabled(false); }); connect(&Settings::Instance(), &Settings::GameListRefreshCompleted, this, [this, empty_msg] { m_empty->setText(empty_msg); m_empty->setEnabled(true); }); } void GameList::resizeEvent(QResizeEvent* event) { OnHeaderViewChanged(); } void GameList::MakeGridView() { m_grid = new GameListListView(this); m_grid->setModel(m_grid_proxy); m_grid->setSelectionMode(QAbstractItemView::ExtendedSelection); // Row in this context refers to the full set of columns (banner, title, region, etc.) associated // with a single game rather than multiple games presented visually on the same row. Even though // most of these elements are hidden in grid view selecting all of them allows us to use // selectedRow() which only returns rows where all elements are selected. In addition to improving // consistency with list view this avoids an edge case where clicking a game in grid mode selected // only the first element of the row but ctrl+a selected every element of the row, making a bunch // of duplicate selections for each game. m_grid->setSelectionBehavior(QAbstractItemView::SelectRows); m_grid->setViewMode(QListView::IconMode); m_grid->setResizeMode(QListView::Adjust); m_grid->setUniformItemSizes(true); m_grid->setContextMenuPolicy(Qt::CustomContextMenu); m_grid->setFrameStyle(QFrame::NoFrame); // Work around a Qt bug where clicking in the background (below the last game) as the first action // and then pressing a key (e.g. page down or end) selects the first entry in the list instead of // performing that key's action. This workaround does not work if there are no games when the // view first appears, but then games are added (e.g. due to no game folders being present, // and then the user adding one), but that is an infrequent situation. m_grid->setCurrentIndex(m_grid->indexAt(QPoint(0, 0))); m_grid->clearSelection(); connect(m_grid, &QTableView::customContextMenuRequested, this, &GameList::ShowContextMenu); connect(m_grid->selectionModel(), &QItemSelectionModel::selectionChanged, [this](const QItemSelection&, const QItemSelection&) { emit SelectionChanged(GetSelectedGame()); }); } void GameList::ShowHeaderContextMenu(const QPoint& pos) { const MenuBar* const menu_bar = MenuBar::GetMenuBar(); if (!menu_bar) return; QMenu* const list_columns_menu = menu_bar->GetListColumnsMenu(); if (!list_columns_menu) return; const QWidget* const widget = qobject_cast(sender()); list_columns_menu->exec(widget ? widget->mapToGlobal(pos) : pos); } void GameList::ShowContextMenu(const QPoint&) { if (!GetSelectedGame()) return; auto& system = Core::System::GetInstance(); QMenu* menu = new QMenu(this); menu->setAttribute(Qt::WA_DeleteOnClose, true); if (HasMultipleSelected()) { const auto selected_games = GetSelectedGames(); if (std::ranges::all_of(selected_games, [](const auto& game) { return game->ShouldAllowConversion(); })) { menu->addAction(tr("Convert Selected Files..."), this, &GameList::ConvertFile); menu->addSeparator(); } if (std::ranges::all_of(selected_games, [](const auto& game) { return DiscIO::IsWii(game->GetPlatform()); })) { menu->addAction(tr("Export Wii Saves"), this, &GameList::ExportWiiSave); menu->addSeparator(); } menu->addAction(tr("Delete Selected Files..."), this, &GameList::DeleteFile); } else { const auto game = GetSelectedGame(); const bool is_mod_descriptor = game->IsModDescriptor(); DiscIO::Platform platform = game->GetPlatform(); menu->addAction(tr("&Properties"), this, &GameList::OpenProperties); if (!is_mod_descriptor && platform != DiscIO::Platform::ELFOrDOL) { menu->addAction(tr("&Wiki"), this, &GameList::OpenWiki); } menu->addSeparator(); if (!is_mod_descriptor && DiscIO::IsDisc(platform)) { menu->addAction(tr("Start with Riivolution Patches..."), this, &GameList::StartWithRiivolution); menu->addSeparator(); menu->addAction(tr("Set as &Default ISO"), this, &GameList::SetDefaultISO); if (game->ShouldAllowConversion()) menu->addAction(tr("Convert File..."), this, &GameList::ConvertFile); QAction* change_disc = menu->addAction(tr("Change &Disc"), this, &GameList::ChangeDisc); connect(&Settings::Instance(), &Settings::EmulationStateChanged, change_disc, [&system, change_disc] { change_disc->setEnabled(Core::IsRunning(system)); }); change_disc->setEnabled(Core::IsRunning(system)); menu->addSeparator(); } if (!is_mod_descriptor && platform == DiscIO::Platform::WiiDisc) { auto* perform_disc_update = menu->addAction(tr("Perform System Update"), this, [this, file_path = game->GetFilePath()] { WiiUpdate::PerformDiscUpdate(file_path, this); // Since the update may have installed a newer // system menu, trigger a refresh. Settings::Instance().NANDRefresh(); }); perform_disc_update->setEnabled(Core::IsUninitialized(system) || !system.IsWii()); } if (!is_mod_descriptor && platform == DiscIO::Platform::WiiWAD) { QAction* wad_install_action = new QAction(tr("Install to the NAND"), menu); QAction* wad_uninstall_action = new QAction(tr("Uninstall from the NAND"), menu); connect(wad_install_action, &QAction::triggered, this, &GameList::InstallWAD); connect(wad_uninstall_action, &QAction::triggered, this, &GameList::UninstallWAD); for (QAction* a : {wad_install_action, wad_uninstall_action}) { a->setEnabled(Core::IsUninitialized(system)); menu->addAction(a); } if (Core::IsUninitialized(system)) wad_uninstall_action->setEnabled(WiiUtils::IsTitleInstalled(game->GetTitleID())); connect(&Settings::Instance(), &Settings::EmulationStateChanged, menu, [=](Core::State state) { wad_install_action->setEnabled(state == Core::State::Uninitialized); wad_uninstall_action->setEnabled(state == Core::State::Uninitialized && WiiUtils::IsTitleInstalled(game->GetTitleID())); }); menu->addSeparator(); } if (!is_mod_descriptor && (platform == DiscIO::Platform::WiiWAD || platform == DiscIO::Platform::WiiDisc)) { QAction* open_wii_save_folder = menu->addAction(tr("Open Wii &Save Folder"), this, &GameList::OpenWiiSaveFolder); QAction* export_wii_save = menu->addAction(tr("Export Wii Save"), this, &GameList::ExportWiiSave); open_wii_save_folder->setEnabled(Core::IsUninitialized(system)); export_wii_save->setEnabled(Core::IsUninitialized(system)); menu->addSeparator(); } if (!is_mod_descriptor && platform == DiscIO::Platform::GameCubeDisc) { menu->addAction(tr("Open GameCube &Save Folder"), this, &GameList::OpenGCSaveFolder); menu->addSeparator(); } menu->addAction(tr("Open &Containing Folder"), this, &GameList::OpenContainingFolder); menu->addAction(tr("Delete File..."), this, &GameList::DeleteFile); #ifdef _WIN32 menu->addAction(tr("Add Shortcut to Desktop"), this, [this] { if (!AddShortcutToDesktop()) { ModalMessageBox::critical(this, tr("Add Shortcut to Desktop"), tr("There was an issue adding a shortcut to the desktop")); } }); #endif menu->addSeparator(); auto* const tags_menu{new QtUtils::NonAutodismissibleMenu(tr("Tags"), menu)}; menu->addMenu(tags_menu); auto path = game->GetFilePath(); auto game_tags = m_model.GetGameTags(path); for (const auto& tag : m_model.GetAllTags()) { auto* tag_action = tags_menu->addAction(tag); tag_action->setCheckable(true); tag_action->setChecked(game_tags.contains(tag)); connect(tag_action, &QAction::toggled, [path, tag, model = &m_model](bool checked) { if (!checked) model->RemoveGameTag(path, tag); else model->AddGameTag(path, tag); }); } menu->addAction(tr("New Tag..."), this, &GameList::NewTag); menu->addAction(tr("Remove Tag..."), this, &GameList::DeleteTag); menu->addSeparator(); QAction* netplay_host = new QAction(tr("Host with NetPlay"), menu); connect(netplay_host, &QAction::triggered, [this, game] { emit NetPlayHost(*game); }); connect(&Settings::Instance(), &Settings::EmulationStateChanged, menu, [=](Core::State state) { netplay_host->setEnabled(state == Core::State::Uninitialized); }); netplay_host->setEnabled(Core::IsUninitialized(system)); menu->addAction(netplay_host); } menu->exec(QCursor::pos()); } void GameList::OpenProperties() { const auto game = GetSelectedGame(); if (!game) return; auto property_windows = this->findChildren(); auto it = std::ranges::find(property_windows, game->GetFilePath(), &PropertiesDialog::GetFilePath); if (it != property_windows.end()) { (*it)->raise(); return; } PropertiesDialog* properties = new PropertiesDialog(this, *game); connect(properties, &PropertiesDialog::OpenGeneralSettings, this, &GameList::OpenGeneralSettings); connect(properties, &PropertiesDialog::OpenGraphicsSettings, this, &GameList::OpenGraphicsSettings); connect(properties, &PropertiesDialog::finished, this, [properties] { properties->deleteLater(); }); #ifdef USE_RETRO_ACHIEVEMENTS connect(properties, &PropertiesDialog::OpenAchievementSettings, this, &GameList::OpenAchievementSettings); #endif // USE_RETRO_ACHIEVEMENTS properties->show(); } void GameList::ExportWiiSave() { const QString export_dir = DolphinFileDialog::getExistingDirectory( this, tr("Select Export Directory"), QString::fromStdString(File::GetUserPath(D_USER_IDX)), QFileDialog::ShowDirsOnly); if (export_dir.isEmpty()) return; QList failed; for (const auto& game : GetSelectedGames()) { if (WiiSave::Export(game->GetTitleID(), export_dir.toStdString()) != WiiSave::CopyResult::Success) { failed.push_back(game->GetName(UICommon::GameFile::Variant::LongAndPossiblyCustom)); } } if (!failed.isEmpty()) { QString failed_str; for (const std::string& str : failed) failed_str.append(QLatin1Char{'\n'}).append(QString::fromStdString(str)); ModalMessageBox::critical(this, tr("Save Export"), tr("Failed to export the following save files:") + failed_str); } else { ModalMessageBox::information(this, tr("Save Export"), tr("Successfully exported save files")); } } void GameList::OpenWiki() { const auto game = GetSelectedGame(); if (!game) return; QString game_id = QString::fromStdString(game->GetGameID()); QString url = QStringLiteral("https://wiki.dolphin-emu.org/dolphin-redirect.php?gameid=").append(game_id); QDesktopServices::openUrl(QUrl(url)); } void GameList::ConvertFile() { auto games = GetSelectedGames(); if (games.empty()) return; ConvertDialog dialog{std::move(games), this}; dialog.exec(); } void GameList::InstallWAD() { const auto game = GetSelectedGame(); if (!game) return; ModalMessageBox result_dialog(this); const bool success = WiiUtils::InstallWAD(game->GetFilePath()); result_dialog.setIcon(success ? QMessageBox::Information : QMessageBox::Critical); result_dialog.setWindowTitle(success ? tr("Success") : tr("Failure")); result_dialog.setText(success ? tr("Successfully installed this title to the NAND.") : tr("Failed to install this title to the NAND.")); result_dialog.exec(); } void GameList::UninstallWAD() { const auto game = GetSelectedGame(); if (!game) return; ModalMessageBox warning_dialog(this); warning_dialog.setIcon(QMessageBox::Information); warning_dialog.setWindowTitle(tr("Confirm")); warning_dialog.setText(tr("Uninstalling the WAD will remove the currently installed version of " "this title from the NAND without deleting its save data. Continue?")); warning_dialog.setStandardButtons(QMessageBox::No | QMessageBox::Yes); if (warning_dialog.exec() == QMessageBox::No) return; ModalMessageBox result_dialog(this); const bool success = WiiUtils::UninstallTitle(game->GetTitleID()); result_dialog.setIcon(success ? QMessageBox::Information : QMessageBox::Critical); result_dialog.setWindowTitle(success ? tr("Success") : tr("Failure")); result_dialog.setText(success ? tr("Successfully removed this title from the NAND.") : tr("Failed to remove this title from the NAND.")); result_dialog.exec(); } void GameList::StartWithRiivolution() { const auto game = GetSelectedGame(); if (!game) return; emit OnStartWithRiivolution(*game); } void GameList::SetDefaultISO() { const auto game = GetSelectedGame(); if (!game) return; Settings::Instance().SetDefaultGame( QDir::toNativeSeparators(QString::fromStdString(game->GetFilePath()))); } void GameList::OpenContainingFolder() { const auto game = GetSelectedGame(); if (!game) return; QtUtils::ShowFileInFolder(game->GetFilePath()); } void GameList::OpenWiiSaveFolder() { const auto game = GetSelectedGame(); if (!game) return; const std::string path = game->GetWiiFSPath(); if (!File::Exists(path)) { ModalMessageBox::information(this, tr("Information"), tr("No save data found.")); return; } const QUrl url = QUrl::fromLocalFile(QString::fromStdString(path)); QDesktopServices::openUrl(url); } void GameList::OpenGCSaveFolder() { const auto game = GetSelectedGame(); if (!game) return; bool found = false; using ExpansionInterface::Slot; for (Slot slot : ExpansionInterface::MEMCARD_SLOTS) { const ExpansionInterface::EXIDeviceType current_exi_device = Config::Get(Config::GetInfoForEXIDevice(slot)); switch (current_exi_device) { case ExpansionInterface::EXIDeviceType::MemoryCardFolder: { namespace fs = std::filesystem; std::string override_path = Config::Get(Config::GetInfoForGCIPathOverride(slot)); const auto dir = override_path.empty() ? Config::GetGCIFolderPath(slot, game->GetRegion()) : override_path; const auto gci_filename_prefix = fs::path{fmt::format("{}-{}-", game->GetMakerID(), game->GetGameID().substr(0, 4))} .native(); const auto gci_extension = fs::path{".gci"}.native(); for (const auto& entry : fs::directory_iterator(dir)) { if (entry.path().filename().native().starts_with(gci_filename_prefix) && entry.path().extension() == gci_extension) { QtUtils::ShowFileInFolder(entry.path().generic_string()); found = true; break; } } break; } case ExpansionInterface::EXIDeviceType::MemoryCard: { QtUtils::ShowFileInFolder(Config::GetMemcardPath(slot, game->GetRegion())); found = true; break; } default: break; } } if (!found) ModalMessageBox::information(this, tr("Information"), tr("No save data found.")); } #ifdef _WIN32 bool GameList::AddShortcutToDesktop() { auto init = wil::CoInitializeEx_failfast(COINIT_APARTMENTTHREADED); auto shell_link = wil::CoCreateInstanceNoThrow(); if (!shell_link) return false; std::wstring dolphin_path = QCoreApplication::applicationFilePath().toStdWString(); if (FAILED(shell_link->SetPath(dolphin_path.c_str()))) return false; const auto game = GetSelectedGame(); const auto& file_path = game->GetFilePath(); std::wstring args = UTF8ToTStr("-e \"" + file_path + "\""); if (FAILED(shell_link->SetArguments(args.c_str()))) return false; wil::unique_cotaskmem_string desktop; if (FAILED(SHGetKnownFolderPath(FOLDERID_Desktop, KF_FLAG_NO_ALIAS, nullptr, &desktop))) return false; std::string game_name = game->GetName(Core::TitleDatabase()); // Sanitize the string by removing all characters that cannot be used in NTFS file names std::erase_if(game_name, [](char ch) { static constexpr char illegal_characters[] = {'<', '>', ':', '\"', '/', '\\', '|', '?', '*'}; return Common::Contains(illegal_characters, ch); }); std::wstring desktop_path = std::wstring(desktop.get()) + UTF8ToTStr("\\" + game_name + ".lnk"); auto persist_file = shell_link.try_query(); if (!persist_file) return false; if (FAILED(persist_file->Save(desktop_path.c_str(), TRUE))) return false; return true; } #endif void GameList::DeleteFile() { ModalMessageBox confirm_dialog(this); confirm_dialog.setIcon(QMessageBox::Warning); confirm_dialog.setWindowTitle(tr("Confirm")); confirm_dialog.setText(tr("Are you sure you want to delete this file?")); confirm_dialog.setInformativeText(tr("This cannot be undone!")); confirm_dialog.setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); if (confirm_dialog.exec() == QMessageBox::Yes) { for (const auto& game : GetSelectedGames()) { bool deletion_successful = false; while (!deletion_successful) { deletion_successful = File::Delete(game->GetFilePath()); if (deletion_successful) { m_model.RemoveGame(game->GetFilePath()); } else { ModalMessageBox error_dialog(this); error_dialog.setIcon(QMessageBox::Critical); error_dialog.setWindowTitle(tr("Failure")); error_dialog.setText(tr("Failed to delete the selected file.")); error_dialog.setInformativeText(tr("Check whether you have the permissions required to " "delete the file or whether it's still in use.")); error_dialog.setStandardButtons(QMessageBox::Retry | QMessageBox::Abort); if (error_dialog.exec() == QMessageBox::Abort) break; } } if (!deletion_successful) break; // Something is wrong, so we should abort the whole thing } } } void GameList::ChangeDisc() { const auto game = GetSelectedGame(); if (!game) return; auto& system = Core::System::GetInstance(); system.GetDVDInterface().ChangeDisc(Core::CPUThreadGuard{system}, game->GetFilePath()); } QAbstractItemView* GameList::GetActiveView() const { if (currentWidget() == m_list) { return m_list; } return m_grid; } QSortFilterProxyModel* GameList::GetActiveProxyModel() const { if (currentWidget() == m_list) { return m_list_proxy; } return m_grid_proxy; } std::shared_ptr GameList::GetSelectedGame() const { QItemSelectionModel* const sel_model = GetActiveView()->selectionModel(); if (sel_model->hasSelection()) { QSortFilterProxyModel* const proxy = GetActiveProxyModel(); QModelIndex model_index = proxy->mapToSource(sel_model->selectedIndexes()[0]); return m_model.GetGameFile(model_index.row()); } return {}; } QList> GameList::GetSelectedGames() const { QList> selected_list; QItemSelectionModel* const sel_model = GetActiveView()->selectionModel(); if (sel_model->hasSelection()) { const QModelIndexList index_list = sel_model->selectedRows(); for (const auto& index : index_list) { const QModelIndex model_index = GetActiveProxyModel()->mapToSource(index); selected_list.push_back(m_model.GetGameFile(model_index.row())); } } return selected_list; } bool GameList::HasMultipleSelected() const { return GetActiveView()->selectionModel()->selectedRows().size() > 1; } std::shared_ptr GameList::FindGame(const std::string& path) const { return m_model.FindGame(path); } std::shared_ptr GameList::FindSecondDisc(const UICommon::GameFile& game) const { return m_model.FindSecondDisc(game); } std::string GameList::GetNetPlayName(const UICommon::GameFile& game) const { return m_model.GetNetPlayName(game); } void GameList::SetViewColumn(int col, bool view) { m_list->setColumnHidden(col, !view); } void GameList::SetPreferredView(bool list) { m_prefer_list = list; Settings::Instance().SetPreferredView(list); ConsiderViewChange(); } void GameList::ConsiderViewChange() { if (m_model.rowCount(QModelIndex()) > 0) { if (m_prefer_list) setCurrentWidget(m_list); else setCurrentWidget(m_grid); } else { setCurrentWidget(m_empty); } } void GameList::keyPressEvent(QKeyEvent* event) { if (event->key() == Qt::Key_Return && GetSelectedGame() != nullptr) { if (event->modifiers() == Qt::AltModifier) OpenProperties(); else emit GameSelected(); } else { QStackedWidget::keyPressEvent(event); } } void GameList::OnColumnVisibilityToggled(const QString& row, bool visible) { using Column = GameListModel::Column; static const QMap rowname_to_column = { {tr("Platform"), Column::Platform}, {tr("Banner"), Column::Banner}, {tr("Title"), Column::Title}, {tr("Description"), Column::Description}, {tr("Maker"), Column::Maker}, {tr("File Name"), Column::FileName}, {tr("File Path"), Column::FilePath}, {tr("Game ID"), Column::ID}, {tr("Region"), Column::Country}, {tr("File Size"), Column::Size}, {tr("File Format"), Column::FileFormat}, {tr("Block Size"), Column::BlockSize}, {tr("Compression"), Column::Compression}, {tr("Time Played"), Column::TimePlayed}, {tr("Tags"), Column::Tags}, }; m_list->setColumnHidden(static_cast(rowname_to_column[row]), !visible); } void GameList::OnGameListVisibilityChanged() { m_list_proxy->invalidate(); m_grid_proxy->invalidate(); UpdateGameCount(); } void GameList::OnSectionResized(int index, int, int) { auto* hor_header = m_list->horizontalHeader(); const int vis_index = hor_header->visualIndex(index); const int col_count = hor_header->count() - hor_header->hiddenSectionCount(); bool last = true; for (int i = vis_index + 1; i < col_count; i++) { const int logical_index = hor_header->logicalIndex(i); if (hor_header->sectionResizeMode(logical_index) != QHeaderView::Interactive) continue; last = false; break; } if (!last) { std::vector sections; for (int i = 0; i < vis_index; i++) { const int logical_index = hor_header->logicalIndex(i); if (hor_header->sectionResizeMode(logical_index) != QHeaderView::Interactive) continue; hor_header->setSectionResizeMode(logical_index, QHeaderView::Fixed); sections.push_back(i); } OnHeaderViewChanged(); for (int i : sections) { hor_header->setSectionResizeMode(hor_header->logicalIndex(i), QHeaderView::Interactive); } } else { OnHeaderViewChanged(); } } void GameList::OnHeaderViewChanged() { static bool block = false; if (block) return; block = true; UpdateColumnVisibility(); // So here's the deal: Qt's way of resizing stuff around stretched columns sucks ass // That's why instead of using Stretch, we'll just make resizable columns take all the available // space ourselves! int available_width = width() - style()->pixelMetric(QStyle::PM_ScrollBarExtent); int previous_width = 0; std::vector candidate_columns; // Iterate through all columns for (int i = 0; i < static_cast(GameListModel::Column::Count); i++) { if (m_list->isColumnHidden(i)) continue; if (m_list->horizontalHeader()->sectionResizeMode(i) == QHeaderView::Fixed) { available_width -= m_list->columnWidth(i); } else { candidate_columns.push_back(i); previous_width += m_list->columnWidth(i); } } for (int column : candidate_columns) { int column_width = static_cast( std::max(5.f, std::ceil(available_width * (static_cast(m_list->columnWidth(column)) / previous_width)))); m_list->setColumnWidth(column, column_width); } block = false; } void GameList::NewTag() { const auto tag = QInputDialog::getText(this, tr("New tag"), tr("Name for a new tag:"), QLineEdit::Normal, QString{}, nullptr, Qt::WindowCloseButtonHint); if (tag.isEmpty()) return; m_model.NewTag(tag); } void GameList::DeleteTag() { const auto tag = QInputDialog::getText(this, tr("Remove tag"), tr("Name of the tag to remove:"), QLineEdit::Normal, QString{}, nullptr, Qt::WindowCloseButtonHint); if (tag.isEmpty()) return; m_model.DeleteTag(tag); } void GameList::SetSearchTerm(const QString& term) { m_model.SetSearchTerm(term); m_list_proxy->invalidate(); m_grid_proxy->invalidate(); UpdateColumnVisibility(); UpdateGameCount(); } void GameList::UpdateGameCount() const { const int total_games = m_model.rowCount(QModelIndex{}); const int visible_games = m_list_proxy->rowCount(); emit GameCountUpdated(total_games, visible_games); } void GameList::ZoomIn() { m_model.SetScale(m_model.GetScale() + 0.1); m_list_proxy->invalidate(); m_grid_proxy->invalidate(); UpdateFont(); } void GameList::ZoomOut() { if (m_model.GetScale() <= 0.1) return; m_model.SetScale(m_model.GetScale() - 0.1); m_list_proxy->invalidate(); m_grid_proxy->invalidate(); UpdateFont(); } void GameList::UpdateFont() { QFont f; f.setPointSizeF(m_model.GetScale() * f.pointSize()); m_grid->setFont(f); }