diff --git a/dist/qt_themes/default/default.qrc b/dist/qt_themes/default/default.qrc index a524a17e7..974079e78 100644 --- a/dist/qt_themes/default/default.qrc +++ b/dist/qt_themes/default/default.qrc @@ -12,6 +12,18 @@ icons/16x16/lock.png + icons/48x48/bad_folder.png + + icons/48x48/chip.png + + icons/48x48/folder.png + + icons/48x48/plus.png + + icons/48x48/sd_card.png + icons/256x256/citra.png + + icons/256x256/plus_folder.png diff --git a/dist/qt_themes/default/icons/256x256/plus_folder.png b/dist/qt_themes/default/icons/256x256/plus_folder.png new file mode 100644 index 000000000..5822f34c5 Binary files /dev/null and b/dist/qt_themes/default/icons/256x256/plus_folder.png differ diff --git a/dist/qt_themes/default/icons/48x48/bad_folder.png b/dist/qt_themes/default/icons/48x48/bad_folder.png new file mode 100644 index 000000000..32e5bffc2 Binary files /dev/null and b/dist/qt_themes/default/icons/48x48/bad_folder.png differ diff --git a/dist/qt_themes/default/icons/48x48/chip.png b/dist/qt_themes/default/icons/48x48/chip.png new file mode 100644 index 000000000..d07a85aca Binary files /dev/null and b/dist/qt_themes/default/icons/48x48/chip.png differ diff --git a/dist/qt_themes/default/icons/48x48/folder.png b/dist/qt_themes/default/icons/48x48/folder.png new file mode 100644 index 000000000..3fb82f3ae Binary files /dev/null and b/dist/qt_themes/default/icons/48x48/folder.png differ diff --git a/dist/qt_themes/default/icons/48x48/plus.png b/dist/qt_themes/default/icons/48x48/plus.png new file mode 100644 index 000000000..dbc74687b Binary files /dev/null and b/dist/qt_themes/default/icons/48x48/plus.png differ diff --git a/dist/qt_themes/default/icons/48x48/sd_card.png b/dist/qt_themes/default/icons/48x48/sd_card.png new file mode 100644 index 000000000..3226347e2 Binary files /dev/null and b/dist/qt_themes/default/icons/48x48/sd_card.png differ diff --git a/dist/qt_themes/default/icons/index.theme b/dist/qt_themes/default/icons/index.theme index ac67cb236..1edbe6408 100644 --- a/dist/qt_themes/default/icons/index.theme +++ b/dist/qt_themes/default/icons/index.theme @@ -1,10 +1,13 @@ [Icon Theme] Name=default Comment=default theme -Directories=16x16,256x256 +Directories=16x16,48x48,256x256 [16x16] Size=16 + +[48x48] +Size=48 [256x256] Size=256 \ No newline at end of file diff --git a/dist/qt_themes/qdarkstyle/icons/256x256/plus_folder.png b/dist/qt_themes/qdarkstyle/icons/256x256/plus_folder.png new file mode 100644 index 000000000..d26c08d6b Binary files /dev/null and b/dist/qt_themes/qdarkstyle/icons/256x256/plus_folder.png differ diff --git a/dist/qt_themes/qdarkstyle/icons/48x48/bad_folder.png b/dist/qt_themes/qdarkstyle/icons/48x48/bad_folder.png new file mode 100644 index 000000000..f7f383089 Binary files /dev/null and b/dist/qt_themes/qdarkstyle/icons/48x48/bad_folder.png differ diff --git a/dist/qt_themes/qdarkstyle/icons/48x48/chip.png b/dist/qt_themes/qdarkstyle/icons/48x48/chip.png new file mode 100644 index 000000000..f1fa5020a Binary files /dev/null and b/dist/qt_themes/qdarkstyle/icons/48x48/chip.png differ diff --git a/dist/qt_themes/qdarkstyle/icons/48x48/folder.png b/dist/qt_themes/qdarkstyle/icons/48x48/folder.png new file mode 100644 index 000000000..dee3078d5 Binary files /dev/null and b/dist/qt_themes/qdarkstyle/icons/48x48/folder.png differ diff --git a/dist/qt_themes/qdarkstyle/icons/48x48/plus.png b/dist/qt_themes/qdarkstyle/icons/48x48/plus.png new file mode 100644 index 000000000..16cc8b4f4 Binary files /dev/null and b/dist/qt_themes/qdarkstyle/icons/48x48/plus.png differ diff --git a/dist/qt_themes/qdarkstyle/icons/48x48/sd_card.png b/dist/qt_themes/qdarkstyle/icons/48x48/sd_card.png new file mode 100644 index 000000000..9052ae794 Binary files /dev/null and b/dist/qt_themes/qdarkstyle/icons/48x48/sd_card.png differ diff --git a/dist/qt_themes/qdarkstyle/icons/index.theme b/dist/qt_themes/qdarkstyle/icons/index.theme index 558ece40b..d1e12f3ef 100644 --- a/dist/qt_themes/qdarkstyle/icons/index.theme +++ b/dist/qt_themes/qdarkstyle/icons/index.theme @@ -2,10 +2,13 @@ Name=qdarkstyle Comment=dark theme Inherits=default -Directories=16x16,256x256 +Directories=16x16,48x48,256x256 [16x16] Size=16 - + +[48x48] +Size=48 + [256x256] Size=256 \ No newline at end of file diff --git a/dist/qt_themes/qdarkstyle/style.qrc b/dist/qt_themes/qdarkstyle/style.qrc index 54a96b680..c2c14c28a 100644 --- a/dist/qt_themes/qdarkstyle/style.qrc +++ b/dist/qt_themes/qdarkstyle/style.qrc @@ -2,6 +2,12 @@ icons/index.theme icons/16x16/lock.png + icons/48x48/bad_folder.png + icons/48x48/chip.png + icons/48x48/folder.png + icons/48x48/plus.png + icons/48x48/sd_card.png + icons/256x256/plus_folder.png rc/up_arrow_disabled.png diff --git a/src/citra_qt/configuration/config.cpp b/src/citra_qt/configuration/config.cpp index bdb296659..3e808dfa4 100644 --- a/src/citra_qt/configuration/config.cpp +++ b/src/citra_qt/configuration/config.cpp @@ -202,8 +202,34 @@ void Config::ReadValues() { qt_config->beginGroup("Paths"); UISettings::values.roms_path = qt_config->value("romsPath").toString(); UISettings::values.symbols_path = qt_config->value("symbolsPath").toString(); - UISettings::values.gamedir = qt_config->value("gameListRootDir", ".").toString(); - UISettings::values.gamedir_deepscan = qt_config->value("gameListDeepScan", false).toBool(); + UISettings::values.game_dir_deprecated = qt_config->value("gameListRootDir", ".").toString(); + UISettings::values.game_dir_deprecated_deepscan = + qt_config->value("gameListDeepScan", false).toBool(); + int size = qt_config->beginReadArray("gamedirs"); + for (int i = 0; i < size; ++i) { + qt_config->setArrayIndex(i); + UISettings::GameDir game_dir; + game_dir.path = qt_config->value("path").toString(); + game_dir.deep_scan = qt_config->value("deep_scan", false).toBool(); + game_dir.expanded = qt_config->value("expanded", true).toBool(); + UISettings::values.game_dirs.append(game_dir); + } + qt_config->endArray(); + // create NAND and SD card directories if empty, these are not removable through the UI, also + // carries over old game list settings if present + if (UISettings::values.game_dirs.isEmpty()) { + UISettings::GameDir game_dir; + game_dir.path = "INSTALLED"; + game_dir.expanded = true; + UISettings::values.game_dirs.append(game_dir); + game_dir.path = "SYSTEM"; + UISettings::values.game_dirs.append(game_dir); + if (UISettings::values.game_dir_deprecated != ".") { + game_dir.path = UISettings::values.game_dir_deprecated; + game_dir.deep_scan = UISettings::values.game_dir_deprecated_deepscan; + UISettings::values.game_dirs.append(game_dir); + } + } UISettings::values.recent_files = qt_config->value("recentFiles").toStringList(); UISettings::values.language = qt_config->value("language", "").toString(); qt_config->endGroup(); @@ -378,8 +404,15 @@ void Config::SaveValues() { qt_config->beginGroup("Paths"); qt_config->setValue("romsPath", UISettings::values.roms_path); qt_config->setValue("symbolsPath", UISettings::values.symbols_path); - qt_config->setValue("gameListRootDir", UISettings::values.gamedir); - qt_config->setValue("gameListDeepScan", UISettings::values.gamedir_deepscan); + qt_config->beginWriteArray("gamedirs"); + for (int i = 0; i < UISettings::values.game_dirs.size(); ++i) { + qt_config->setArrayIndex(i); + const auto& game_dir = UISettings::values.game_dirs.at(i); + qt_config->setValue("path", game_dir.path); + qt_config->setValue("deep_scan", game_dir.deep_scan); + qt_config->setValue("expanded", game_dir.expanded); + } + qt_config->endArray(); qt_config->setValue("recentFiles", UISettings::values.recent_files); qt_config->setValue("language", UISettings::values.language); qt_config->endGroup(); diff --git a/src/citra_qt/configuration/configure_general.cpp b/src/citra_qt/configuration/configure_general.cpp index ad008a011..0bdeeeb3d 100644 --- a/src/citra_qt/configuration/configure_general.cpp +++ b/src/citra_qt/configuration/configure_general.cpp @@ -44,7 +44,6 @@ ConfigureGeneral::ConfigureGeneral(QWidget* parent) ConfigureGeneral::~ConfigureGeneral() {} void ConfigureGeneral::setConfiguration() { - ui->toggle_deepscan->setChecked(UISettings::values.gamedir_deepscan); ui->toggle_check_exit->setChecked(UISettings::values.confirm_before_closing); ui->toggle_cpu_jit->setChecked(Settings::values.use_cpu_jit); @@ -60,7 +59,6 @@ void ConfigureGeneral::setConfiguration() { } void ConfigureGeneral::applyConfiguration() { - UISettings::values.gamedir_deepscan = ui->toggle_deepscan->isChecked(); UISettings::values.confirm_before_closing = ui->toggle_check_exit->isChecked(); UISettings::values.theme = ui->theme_combobox->itemData(ui->theme_combobox->currentIndex()).toString(); diff --git a/src/citra_qt/configuration/configure_general.ui b/src/citra_qt/configuration/configure_general.ui index c2bf24b52..7a949979a 100644 --- a/src/citra_qt/configuration/configure_general.ui +++ b/src/citra_qt/configuration/configure_general.ui @@ -7,7 +7,7 @@ 0 0 345 - 493 + 504 @@ -31,13 +31,6 @@ - - - - Search sub-directories for games - - - diff --git a/src/citra_qt/game_list.cpp b/src/citra_qt/game_list.cpp index 14e647559..e01c302b7 100644 --- a/src/citra_qt/game_list.cpp +++ b/src/citra_qt/game_list.cpp @@ -66,6 +66,7 @@ bool GameList::SearchField::KeyReleaseEater::eventFilter(QObject* obj, QEvent* e case Qt::Key_Enter: { if (gamelist->search_field->visible == 1) { QString file_path = gamelist->getLastFilterResultItem(); + // To avoid loading error dialog loops while confirming them using enter // Also users usually want to run a diffrent game after closing one gamelist->search_field->edit_filter->setText(""); @@ -171,6 +172,15 @@ bool GameList::containsAllWords(QString haystack, QString userinput) { [haystack](QString s) { return haystack.contains(s); }); } +// Syncs the expanded state of Game Directories with settings to persist across sessions +void GameList::onItemExpanded(const QModelIndex& item) { + GameListItemType type = item.data(GameListItem::TypeRole).value(); + if (type == GameListItemType::CustomDir || type == GameListItemType::InstalledDir || + type == GameListItemType::SystemDir) + item.data(GameListDir::GameDirRole).value()->expanded = + tree_view->isExpanded(item); +} + // Event in order to filter the gamelist after editing the searchfield void GameList::onTextChanged(const QString& newText) { int folderCount = tree_view->model()->rowCount(); @@ -226,6 +236,31 @@ void GameList::onTextChanged(const QString& newText) { } } +void GameList::onUpdateThemedIcons() { + for (int i = 0; i < item_model->invisibleRootItem()->rowCount(); i++) { + QStandardItem* child = item_model->invisibleRootItem()->child(i); + + switch (child->data(GameListItem::TypeRole).value()) { + case GameListItemType::InstalledDir: + child->setData(QIcon::fromTheme("sd_card").pixmap(48), Qt::DecorationRole); + break; + case GameListItemType::SystemDir: + child->setData(QIcon::fromTheme("chip").pixmap(48), Qt::DecorationRole); + break; + case GameListItemType::CustomDir: { + const UISettings::GameDir* game_dir = + child->data(GameListDir::GameDirRole).value(); + QString icon_name = QFileInfo::exists(game_dir->path) ? "folder" : "bad_folder"; + child->setData(QIcon::fromTheme(icon_name).pixmap(48), Qt::DecorationRole); + break; + } + case GameListItemType::AddDir: + child->setData(QIcon::fromTheme("plus").pixmap(48), Qt::DecorationRole); + break; + } + } +} + void GameList::onFilterCloseClicked() { main_window->filterBarSetChecked(false); } @@ -257,12 +292,16 @@ GameList::GameList(GMainWindow* parent) : QWidget{parent} { item_model->setHeaderData(COLUMN_REGION, Qt::Horizontal, "Region"); item_model->setHeaderData(COLUMN_FILE_TYPE, Qt::Horizontal, "File type"); item_model->setHeaderData(COLUMN_SIZE, Qt::Horizontal, "Size"); + item_model->setSortRole(GameListItemPath::TitleRole); + connect(main_window, &GMainWindow::UpdateThemedIcons, this, &GameList::onUpdateThemedIcons); connect(tree_view, &QTreeView::activated, this, &GameList::ValidateEntry); connect(tree_view, &QTreeView::customContextMenuRequested, this, &GameList::PopupContextMenu); + connect(tree_view, &QTreeView::expanded, this, &GameList::onItemExpanded); + connect(tree_view, &QTreeView::collapsed, this, &GameList::onItemExpanded); - // We must register all custom types with the Qt Automoc system so that we are able to use it - // with signals/slots. In this case, QList falls under the umbrells of custom types. + // We must register all custom types with the Qt Automoc system so that we are able to use + // it with signals/slots. In this case, QList falls under the umbrells of custom types. qRegisterMetaType>("QList"); layout->setContentsMargins(0, 0, 0, 0); @@ -290,27 +329,57 @@ void GameList::clearFilter() { search_field->clear(); } -void GameList::AddEntry(const QList& entry_items) { +void GameList::AddDirEntry(GameListDir* entry_items) { item_model->invisibleRootItem()->appendRow(entry_items); + tree_view->setExpanded( + entry_items->index(), + entry_items->data(GameListDir::GameDirRole).value()->expanded); +} + +void GameList::AddEntry(const QList& entry_items, GameListDir* parent) { + parent->appendRow(entry_items); } void GameList::ValidateEntry(const QModelIndex& item) { - // We don't care about the individual QStandardItem that was selected, but its row. - int row = item_model->itemFromIndex(item)->row(); - QStandardItem* child_file = item_model->invisibleRootItem()->child(row, COLUMN_NAME); - QString file_path = child_file->data(GameListItemPath::FullPathRole).toString(); + auto selected = item.sibling(item.row(), 0); - if (file_path.isEmpty()) - return; - std::string std_file_path(file_path.toStdString()); - if (!FileUtil::Exists(std_file_path) || FileUtil::IsDirectory(std_file_path)) - return; - // Users usually want to run a diffrent game after closing one - search_field->clear(); - emit GameChosen(file_path); + switch (selected.data(GameListItem::TypeRole).value()) { + case GameListItemType::Game: { + QString file_path = selected.data(GameListItemPath::FullPathRole).toString(); + if (file_path.isEmpty()) + return; + QFileInfo file_info(file_path); + if (!file_info.exists() || file_info.isDir()) + return; + // Users usually want to run a different game after closing one + search_field->clear(); + emit GameChosen(file_path); + break; + } + case GameListItemType::AddDir: + emit AddDirectory(); + break; + } +} + +bool GameList::isEmpty() { + for (int i = 0; i < item_model->rowCount(); i++) { + const QStandardItem* child = item_model->invisibleRootItem()->child(i); + GameListItemType type = static_cast(child->type()); + if (!child->hasChildren() && + (type == GameListItemType::InstalledDir || type == GameListItemType::SystemDir)) { + item_model->invisibleRootItem()->removeRow(child->row()); + i--; + }; + } + return !item_model->invisibleRootItem()->hasChildren(); } void GameList::DonePopulating(QStringList watch_list) { + emit ShowList(!isEmpty()); + + item_model->invisibleRootItem()->appendRow(new GameListAddDir()); + // Clear out the old directories to watch for changes and add the new ones auto watch_dirs = watcher->directories(); if (!watch_dirs.isEmpty()) { @@ -346,12 +415,25 @@ void GameList::PopupContextMenu(const QPoint& menu_location) { if (!item.isValid()) return; - int row = item_model->itemFromIndex(item)->row(); - QStandardItem* child_file = item_model->invisibleRootItem()->child(row, COLUMN_NAME); - u64 program_id = child_file->data(GameListItemPath::ProgramIdRole).toULongLong(); - + auto selected = item.sibling(item.row(), 0); QMenu context_menu; + switch (selected.data(GameListItem::TypeRole).value()) { + case GameListItemType::Game: + AddGamePopup(context_menu, selected.data(GameListItemPath::ProgramIdRole).toULongLong()); + break; + case GameListItemType::CustomDir: + AddPermDirPopup(context_menu, selected); + AddCustomDirPopup(context_menu, selected); + break; + case GameListItemType::InstalledDir: + case GameListItemType::SystemDir: + AddPermDirPopup(context_menu, selected); + break; + } + context_menu.exec(tree_view->viewport()->mapToGlobal(menu_location)); +} +void GameList::AddGamePopup(QMenu& context_menu, u64 program_id) { QAction* open_save_location = context_menu.addAction(tr("Open Save Data Location")); QAction* open_application_location = context_menu.addAction(tr("Open Application Location")); QAction* open_update_location = context_menu.addAction(tr("Open Update Data Location")); @@ -370,16 +452,81 @@ void GameList::PopupContextMenu(const QPoint& menu_location) { }); navigate_to_gamedb_entry->setVisible(it != compatibility_list.end()); - connect(open_save_location, &QAction::triggered, - [&]() { emit OpenFolderRequested(program_id, GameListOpenTarget::SAVE_DATA); }); - connect(open_application_location, &QAction::triggered, - [&]() { emit OpenFolderRequested(program_id, GameListOpenTarget::APPLICATION); }); - connect(open_update_location, &QAction::triggered, - [&]() { emit OpenFolderRequested(program_id, GameListOpenTarget::UPDATE_DATA); }); - connect(navigate_to_gamedb_entry, &QAction::triggered, - [&]() { emit NavigateToGamedbEntryRequested(program_id, compatibility_list); }); + connect(open_save_location, &QAction::triggered, [this, program_id] { + emit OpenFolderRequested(program_id, GameListOpenTarget::SAVE_DATA); + }); + connect(open_application_location, &QAction::triggered, [this, program_id] { + emit OpenFolderRequested(program_id, GameListOpenTarget::APPLICATION); + }); + connect(open_update_location, &QAction::triggered, [this, program_id] { + emit OpenFolderRequested(program_id, GameListOpenTarget::UPDATE_DATA); + }); + connect(navigate_to_gamedb_entry, &QAction::triggered, [this, program_id]() { + emit NavigateToGamedbEntryRequested(program_id, compatibility_list); + }); +}; - context_menu.exec(tree_view->viewport()->mapToGlobal(menu_location)); +void GameList::AddCustomDirPopup(QMenu& context_menu, QModelIndex selected) { + UISettings::GameDir& game_dir = + *selected.data(GameListDir::GameDirRole).value(); + + QAction* deep_scan = context_menu.addAction(tr("Scan Subfolders")); + QAction* delete_dir = context_menu.addAction(tr("Remove Game Directory")); + + deep_scan->setCheckable(true); + deep_scan->setChecked(game_dir.deep_scan); + + connect(deep_scan, &QAction::triggered, [this, &game_dir] { + game_dir.deep_scan = !game_dir.deep_scan; + PopulateAsync(UISettings::values.game_dirs); + }); + connect(delete_dir, &QAction::triggered, [this, &game_dir, selected] { + UISettings::values.game_dirs.removeOne(game_dir); + item_model->invisibleRootItem()->removeRow(selected.row()); + }); +} + +void GameList::AddPermDirPopup(QMenu& context_menu, QModelIndex selected) { + UISettings::GameDir& game_dir = + *selected.data(GameListDir::GameDirRole).value(); + + QAction* move_up = context_menu.addAction(tr(u8"\U000025b2 Move Up")); + QAction* move_down = context_menu.addAction(tr(u8"\U000025bc Move Down ")); + QAction* open_directory_location = context_menu.addAction(tr("Open Directory Location")); + + int row = selected.row(); + + move_up->setEnabled(row > 0); + move_down->setEnabled(row < item_model->rowCount() - 2); + + connect(move_up, &QAction::triggered, [this, selected, row, &game_dir] { + // find the indices of the items in settings and swap them + UISettings::values.game_dirs.swap( + UISettings::values.game_dirs.indexOf(game_dir), + UISettings::values.game_dirs.indexOf(*selected.sibling(selected.row() - 1, 0) + .data(GameListDir::GameDirRole) + .value())); + // move the treeview items + QList item = item_model->takeRow(row); + item_model->invisibleRootItem()->insertRow(row - 1, item); + tree_view->setExpanded(selected, game_dir.expanded); + }); + + connect(move_down, &QAction::triggered, [this, selected, row, &game_dir] { + // find the indices of the items in settings and swap them + UISettings::values.game_dirs.swap( + UISettings::values.game_dirs.indexOf(game_dir), + UISettings::values.game_dirs.indexOf(*selected.sibling(selected.row() + 1, 0) + .data(GameListDir::GameDirRole) + .value())); + // move the treeview items + QList item = item_model->takeRow(row); + item_model->invisibleRootItem()->insertRow(row + 1, item); + tree_view->setExpanded(selected, game_dir.expanded); + }); + + connect(open_directory_location, &QAction::triggered, + [this, game_dir] { emit OpenDirectory(game_dir.path); }); } void GameList::LoadCompatibilityList() { @@ -428,14 +575,7 @@ QStandardItemModel* GameList::GetModel() const { return item_model; } -void GameList::PopulateAsync(const QString& dir_path, bool deep_scan) { - if (!FileUtil::Exists(dir_path.toStdString()) || - !FileUtil::IsDirectory(dir_path.toStdString())) { - NGLOG_ERROR(Frontend, "Could not find game list folder at {}", dir_path.toStdString()); - search_field->setFilterResult(0, 0); - return; - } - +void GameList::PopulateAsync(QList& game_dirs) { tree_view->setEnabled(false); // Delete any rows that might already exist if we're repopulating item_model->removeRows(0, item_model->rowCount()); @@ -443,13 +583,15 @@ void GameList::PopulateAsync(const QString& dir_path, bool deep_scan) { emit ShouldCancelWorker(); - GameListWorker* worker = new GameListWorker(dir_path, deep_scan, compatibility_list); + GameListWorker* worker = new GameListWorker(game_dirs, compatibility_list); connect(worker, &GameListWorker::EntryReady, this, &GameList::AddEntry, Qt::QueuedConnection); + connect(worker, &GameListWorker::DirEntryReady, this, &GameList::AddDirEntry, + Qt::QueuedConnection); connect(worker, &GameListWorker::Finished, this, &GameList::DonePopulating, Qt::QueuedConnection); - // Use DirectConnection here because worker->Cancel() is thread-safe and we want it to cancel - // without delay. + // Use DirectConnection here because worker->Cancel() is thread-safe and we want it to + // cancel without delay. connect(this, &GameList::ShouldCancelWorker, worker, &GameListWorker::Cancel, Qt::DirectConnection); @@ -481,15 +623,17 @@ static bool HasSupportedFileExtension(const std::string& file_name) { } void GameList::RefreshGameDirectory() { - if (!UISettings::values.gamedir.isEmpty() && current_worker != nullptr) { + if (!UISettings::values.game_dirs.isEmpty() && current_worker != nullptr) { NGLOG_INFO(Frontend, "Change detected in the games directory. Reloading game list."); - PopulateAsync(UISettings::values.gamedirs); + PopulateAsync(UISettings::values.game_dirs); } } -void GameListWorker::AddFstEntriesToGameList(const std::string& dir_path, unsigned int recursion) { - const auto callback = [this, recursion](unsigned* num_entries_out, const std::string& directory, - const std::string& virtual_name) -> bool { +void GameListWorker::AddFstEntriesToGameList(const std::string& dir_path, unsigned int recursion, + GameListDir* parent_dir) { + const auto callback = [this, recursion, parent_dir](unsigned* num_entries_out, + const std::string& directory, + const std::string& virtual_name) -> bool { std::string physical_name = directory + DIR_SEP + virtual_name; if (stop_processing) @@ -539,17 +683,20 @@ void GameListWorker::AddFstEntriesToGameList(const std::string& dir_path, unsign if (it != compatibility_list.end()) compatibility = it->second.first; - emit EntryReady({ - new GameListItemPath(QString::fromStdString(physical_name), smdh, program_id), - new GameListItemCompat(compatibility), - new GameListItemRegion(smdh), - new GameListItem( - QString::fromStdString(Loader::GetFileTypeString(loader->GetFileType()))), - new GameListItemSize(FileUtil::GetSize(physical_name)), - }); + emit EntryReady( + { + new GameListItemPath(QString::fromStdString(physical_name), smdh, program_id), + new GameListItemCompat(compatibility), + new GameListItemRegion(smdh), + new GameListItem( + QString::fromStdString(Loader::GetFileTypeString(loader->GetFileType()))), + new GameListItemSize(FileUtil::GetSize(physical_name)), + }, + parent_dir); + } else if (is_dir && recursion > 0) { watch_list.append(QString::fromStdString(physical_name)); - AddFstEntriesToGameList(physical_name, recursion - 1); + AddFstEntriesToGameList(physical_name, recursion - 1, parent_dir); } return true; @@ -560,27 +707,33 @@ void GameListWorker::AddFstEntriesToGameList(const std::string& dir_path, unsign void GameListWorker::run() { stop_processing = false; - watch_list.append(dir_path); - watch_list.append(QString::fromStdString( - std::string(FileUtil::GetUserPath(D_SDMC_IDX).c_str()) + - "Nintendo " - "3DS/00000000000000000000000000000000/00000000000000000000000000000000/title/00040000")); - watch_list.append(QString::fromStdString( - std::string(FileUtil::GetUserPath(D_SDMC_IDX).c_str()) + - "Nintendo " - "3DS/00000000000000000000000000000000/00000000000000000000000000000000/title/0004000e")); - watch_list.append( - QString::fromStdString(std::string(FileUtil::GetUserPath(D_NAND_IDX).c_str()) + - "00000000000000000000000000000000/title/00040010")); - AddFstEntriesToGameList(dir_path.toStdString(), deep_scan ? 256 : 0); - AddFstEntriesToGameList( - std::string(FileUtil::GetUserPath(D_SDMC_IDX).c_str()) + - "Nintendo " - "3DS/00000000000000000000000000000000/00000000000000000000000000000000/title/00040000", - 2); - AddFstEntriesToGameList(std::string(FileUtil::GetUserPath(D_NAND_IDX).c_str()) + - "00000000000000000000000000000000/title/00040010", - 2); + for (UISettings::GameDir& game_dir : game_dirs) { + if (game_dir.path == "INSTALLED") { + QString path = QString(FileUtil::GetUserPath(D_SDMC_IDX).c_str()) + + "Nintendo " + "3DS/00000000000000000000000000000000/" + "00000000000000000000000000000000/title/00040000"; + watch_list.append(path); + GameListDir* game_list_dir = new GameListDir(game_dir, GameListItemType::InstalledDir); + emit DirEntryReady({game_list_dir}); + AddFstEntriesToGameList(path.toStdString(), 2, game_list_dir); + } else if (game_dir.path == "SYSTEM") { + QString path = QString(FileUtil::GetUserPath(D_NAND_IDX).c_str()) + + "00000000000000000000000000000000/title/00040010"; + watch_list.append(path); + GameListDir* game_list_dir = new GameListDir(game_dir, GameListItemType::SystemDir); + emit DirEntryReady({game_list_dir}); + AddFstEntriesToGameList(std::string(FileUtil::GetUserPath(D_NAND_IDX).c_str()) + + "00000000000000000000000000000000/title/00040010", + 2, game_list_dir); + } else { + watch_list.append(game_dir.path); + GameListDir* game_list_dir = new GameListDir(game_dir); + emit DirEntryReady({game_list_dir}); + AddFstEntriesToGameList(game_dir.path.toStdString(), game_dir.deep_scan ? 256 : 0, + game_list_dir); + } + }; emit Finished(watch_list); } @@ -588,3 +741,37 @@ void GameListWorker::Cancel() { this->disconnect(); stop_processing = true; } + +GameListPlaceholder::GameListPlaceholder(GMainWindow* parent) : QWidget{parent} { + this->main_window = parent; + + connect(main_window, &GMainWindow::UpdateThemedIcons, this, + &GameListPlaceholder::onUpdateThemedIcons); + + layout = new QVBoxLayout; + image = new QLabel; + text = new QLabel; + layout->setAlignment(Qt::AlignCenter); + image->setPixmap(QIcon::fromTheme("plus_folder").pixmap(200)); + + text->setText(tr("Double-click to add a new folder to the game list ")); + QFont font = text->font(); + font.setPointSize(20); + text->setFont(font); + text->setAlignment(Qt::AlignHCenter); + image->setAlignment(Qt::AlignHCenter); + + layout->addWidget(image); + layout->addWidget(text); + setLayout(layout); +} + +GameListPlaceholder::~GameListPlaceholder() = default; + +void GameListPlaceholder::onUpdateThemedIcons() { + image->setPixmap(QIcon::fromTheme("plus_folder").pixmap(200)); +} + +void GameListPlaceholder::mouseDoubleClickEvent(QMouseEvent* event) { + emit GameListPlaceholder::AddDirectory(); +} diff --git a/src/citra_qt/game_list.h b/src/citra_qt/game_list.h index 96e2c46fd..f600f6ef8 100644 --- a/src/citra_qt/game_list.h +++ b/src/citra_qt/game_list.h @@ -8,13 +8,17 @@ #include #include #include "common/common_types.h" +#include "ui_settings.h" class GameListWorker; +class GameListDir; class GMainWindow; class QFileSystemWatcher; class QHBoxLayout; class QLabel; class QLineEdit; +template +class QList; class QModelIndex; class QStandardItem; class QStandardItemModel; @@ -39,12 +43,14 @@ public: class SearchField : public QWidget { public: - int visible; - int total; + explicit SearchField(GameList* parent = nullptr); + void setFilterResult(int visible, int total); void clear(); void setFocus(); - explicit SearchField(GameList* parent = nullptr); + + int visible; + int total; private: class KeyReleaseEater : public QObject { @@ -73,9 +79,10 @@ public: void clearFilter(); void setFilterFocus(); void setFilterVisible(bool visibility); + bool isEmpty(); void LoadCompatibilityList(); - void PopulateAsync(const QString& dir_path, bool deep_scan); + void PopulateAsync(QList& game_dirs); void SaveInterfaceLayout(); void LoadInterfaceLayout(); @@ -91,20 +98,30 @@ signals: void NavigateToGamedbEntryRequested( u64 program_id, std::unordered_map>& compatibility_list); + void OpenDirectory(QString directory); + void AddDirectory(); + void ShowList(bool show); private slots: + void onItemExpanded(const QModelIndex& item); void onTextChanged(const QString& newText); void onFilterCloseClicked(); + void onUpdateThemedIcons(); private: - void AddEntry(const QList& entry_items); + void AddDirEntry(GameListDir* entry_items); + void AddEntry(const QList& entry_items, GameListDir* parent); void ValidateEntry(const QModelIndex& item); void DonePopulating(QStringList watch_list); - void PopupContextMenu(const QPoint& menu_location); void RefreshGameDirectory(); bool containsAllWords(QString haystack, QString userinput); + void PopupContextMenu(const QPoint& menu_location); + void AddGamePopup(QMenu& context_menu, u64 program_id); + void AddCustomDirPopup(QMenu& context_menu, QModelIndex selected); + void AddPermDirPopup(QMenu& context_menu, QModelIndex selected); + SearchField* search_field; GMainWindow* main_window = nullptr; QVBoxLayout* layout = nullptr; @@ -116,3 +133,25 @@ private: }; Q_DECLARE_METATYPE(GameListOpenTarget); + +class GameListPlaceholder : public QWidget { + Q_OBJECT +public: + explicit GameListPlaceholder(GMainWindow* parent = nullptr); + ~GameListPlaceholder(); + +signals: + void AddDirectory(); + +private slots: + void onUpdateThemedIcons(); + +protected: + void mouseDoubleClickEvent(QMouseEvent* event) override; + +private: + GMainWindow* main_window = nullptr; + QVBoxLayout* layout = nullptr; + QLabel* image = nullptr; + QLabel* text = nullptr; +}; diff --git a/src/citra_qt/game_list_p.h b/src/citra_qt/game_list_p.h index d39281f59..fbbeeb66b 100644 --- a/src/citra_qt/game_list_p.h +++ b/src/citra_qt/game_list_p.h @@ -8,17 +8,29 @@ #include #include #include +#include #include #include #include #include #include #include +#include "citra_qt/ui_settings.h" #include "citra_qt/util/util.h" #include "common/logging/log.h" #include "common/string_util.h" #include "core/loader/smdh.h" +enum class GameListItemType { + Game = QStandardItem::UserType + 1, + CustomDir = QStandardItem::UserType + 2, + InstalledDir = QStandardItem::UserType + 3, + SystemDir = QStandardItem::UserType + 4, + AddDir = QStandardItem::UserType + 5 +}; + +Q_DECLARE_METATYPE(GameListItemType); + /** * Gets the game icon from SMDH data. * @param smdh SMDH data @@ -125,8 +137,13 @@ const static inline std::map status_data = { class GameListItem : public QStandardItem { public: + // used to access type from item index + static const int TypeRole = Qt::UserRole + 1; + static const int SortRole = Qt::UserRole + 2; GameListItem() : QStandardItem() {} - GameListItem(const QString& string) : QStandardItem(string) {} + GameListItem(const QString& string) : QStandardItem(string) { + setData(string, SortRole); + } virtual ~GameListItem() override {} }; @@ -138,13 +155,14 @@ public: */ class GameListItemPath : public GameListItem { public: - static const int FullPathRole = Qt::UserRole + 1; - static const int TitleRole = Qt::UserRole + 2; - static const int ProgramIdRole = Qt::UserRole + 3; + static const int TitleRole = SortRole; + static const int FullPathRole = SortRole + 1; + static const int ProgramIdRole = SortRole + 2; GameListItemPath() : GameListItem() {} GameListItemPath(const QString& game_path, const std::vector& smdh_data, u64 program_id) : GameListItem() { + setData(type(), TypeRole); setData(game_path, FullPathRole); setData(qulonglong(program_id), ProgramIdRole); @@ -165,6 +183,10 @@ public: TitleRole); } + int type() const override { + return static_cast(GameListItemType::Game); + } + QVariant data(int role) const override { if (role == Qt::DisplayRole) { std::string filename; @@ -180,9 +202,12 @@ public: class GameListItemCompat : public GameListItem { public: - static const int CompatNumberRole = Qt::UserRole + 1; + static const int CompatNumberRole = SortRole; + GameListItemCompat() = default; explicit GameListItemCompat(const QString compatiblity) { + setData(type(), TypeRole); + auto iterator = status_data.find(compatiblity); if (iterator == status_data.end()) { NGLOG_WARNING(Frontend, "Invalid compatibility number {}", compatiblity.toStdString()); @@ -195,6 +220,10 @@ public: setData(CreateCirclePixmapFromColor(status.color), Qt::DecorationRole); } + int type() const override { + return static_cast(GameListItemType::Game); + } + bool operator<(const QStandardItem& other) const override { return data(CompatNumberRole) < other.data(CompatNumberRole); } @@ -204,6 +233,8 @@ class GameListItemRegion : public GameListItem { public: GameListItemRegion() = default; explicit GameListItemRegion(const std::vector& smdh_data) { + setData(type(), TypeRole); + if (!Loader::IsValidSMDH(smdh_data)) { setText(QObject::tr("Invalid region")); return; @@ -213,6 +244,11 @@ public: memcpy(&smdh, smdh_data.data(), sizeof(Loader::SMDH)); setText(GetRegionFromSMDH(smdh)); + setData(GetRegionFromSMDH(smdh), SortRole); + } + + int type() const override { + return static_cast(GameListItemType::Game); } }; @@ -223,10 +259,11 @@ public: */ class GameListItemSize : public GameListItem { public: - static const int SizeRole = Qt::UserRole + 1; + static const int SizeRole = SortRole; GameListItemSize() : GameListItem() {} GameListItemSize(const qulonglong size_bytes) : GameListItem() { + setData(type(), TypeRole); setData(size_bytes, SizeRole); } @@ -242,6 +279,10 @@ public: } } + int type() const override { + return static_cast(GameListItemType::Game); + } + /** * This operator is, in practice, only used by the TreeView sorting systems. * Override it so that it will correctly sort by numerical value instead of by string @@ -252,6 +293,55 @@ public: } }; +class GameListDir : public GameListItem { +public: + static const int GameDirRole = Qt::UserRole + 2; + + explicit GameListDir(UISettings::GameDir& directory, + GameListItemType dir_type = GameListItemType::CustomDir) + : dir_type{dir_type} { + setData(type(), TypeRole); + + UISettings::GameDir* game_dir = &directory; + setData(QVariant::fromValue(game_dir), GameDirRole); + switch (dir_type) { + case GameListItemType::InstalledDir: + setData(QIcon::fromTheme("sd_card").pixmap(48), Qt::DecorationRole); + setData("Installed Titles", Qt::DisplayRole); + break; + case GameListItemType::SystemDir: + setData(QIcon::fromTheme("chip").pixmap(48), Qt::DecorationRole); + setData("System Titles", Qt::DisplayRole); + break; + case GameListItemType::CustomDir: + QString icon_name = QFileInfo::exists(game_dir->path) ? "folder" : "bad_folder"; + setData(QIcon::fromTheme(icon_name).pixmap(48), Qt::DecorationRole); + setData(game_dir->path, Qt::DisplayRole); + break; + }; + }; + + int type() const override { + return static_cast(dir_type); + } + +private: + GameListItemType dir_type; +}; + +class GameListAddDir : public GameListItem { +public: + explicit GameListAddDir() { + setData(type(), TypeRole); + setData(QIcon::fromTheme("plus").pixmap(48), Qt::DecorationRole); + setData("Add New Game Directory", Qt::DisplayRole); + } + + int type() const override { + return static_cast(GameListItemType::AddDir); + } +}; + /** * Asynchronous worker object for populating the game list. * Communicates with other threads through Qt's signal/slot system. @@ -260,11 +350,10 @@ class GameListWorker : public QObject, public QRunnable { Q_OBJECT public: - GameListWorker( - QString dir_path, bool deep_scan, + explicit GameListWorker( + QList& game_dirs, const std::unordered_map>& compatibility_list) - : QObject(), QRunnable(), dir_path(dir_path), deep_scan(deep_scan), - compatibility_list(compatibility_list) {} + : QObject(), QRunnable(), game_dirs(game_dirs), compatibility_list(compatibility_list) {} public slots: /// Starts the processing of directory tree information. @@ -276,22 +365,24 @@ signals: /** * The `EntryReady` signal is emitted once an entry has been prepared and is ready * to be added to the game list. - * @param entry_items a list with `QStandardItem`s that make up the columns of the new entry. + * @param entry_items a list with `QStandardItem`s that make up the columns of the new + * entry. */ - void EntryReady(QList entry_items); + void DirEntryReady(GameListDir* entry_items); + void EntryReady(QList entry_items, GameListDir* parent_dir); /** - * After the worker has traversed the game directory looking for entries, this signal is emmited - * with a list of folders that should be watched for changes as well. + * After the worker has traversed the game directory looking for entries, this signal is + * emitted with a list of folders that should be watched for changes as well. */ void Finished(QStringList watch_list); private: QStringList watch_list; - QString dir_path; - bool deep_scan; const std::unordered_map>& compatibility_list; + QList& game_dirs; std::atomic_bool stop_processing; - void AddFstEntriesToGameList(const std::string& dir_path, unsigned int recursion = 0); + void AddFstEntriesToGameList(const std::string& dir_path, unsigned int recursion, + GameListDir* parent_dir); }; diff --git a/src/citra_qt/main.cpp b/src/citra_qt/main.cpp index 887f2c191..2bd2ec9a8 100644 --- a/src/citra_qt/main.cpp +++ b/src/citra_qt/main.cpp @@ -143,7 +143,7 @@ GMainWindow::GMainWindow() : config(new Config()), emu_thread(nullptr) { show(); game_list->LoadCompatibilityList(); - game_list->PopulateAsync(UISettings::values.gamedir, UISettings::values.gamedir_deepscan); + game_list->PopulateAsync(UISettings::values.game_dirs); // Show one-time "callout" messages to the user ShowCallouts(); @@ -177,6 +177,10 @@ void GMainWindow::InitializeWidgets() { game_list = new GameList(this); ui.horizontalLayout->addWidget(game_list); + game_list_placeholder = new GameListPlaceholder(this); + ui.horizontalLayout->addWidget(game_list_placeholder); + game_list_placeholder->setVisible(false); + multiplayer_state = new MultiplayerState(this, game_list->GetModel(), ui.action_Leave_Room, ui.action_Show_Room); multiplayer_state->setVisible(false); @@ -399,9 +403,14 @@ void GMainWindow::RestoreUIState() { void GMainWindow::ConnectWidgetEvents() { connect(game_list, &GameList::GameChosen, this, &GMainWindow::OnGameListLoadFile); + connect(game_list, &GameList::OpenDirectory, this, &GMainWindow::OnGameListOpenDirectory); connect(game_list, &GameList::OpenFolderRequested, this, &GMainWindow::OnGameListOpenFolder); connect(game_list, &GameList::NavigateToGamedbEntryRequested, this, &GMainWindow::OnGameListNavigateToGamedbEntry); + connect(game_list, &GameList::AddDirectory, this, &GMainWindow::OnGameListAddDirectory); + connect(game_list_placeholder, &GameListPlaceholder::AddDirectory, this, + &GMainWindow::OnGameListAddDirectory); + connect(game_list, &GameList::ShowList, this, &GMainWindow::OnGameListShowList); connect(this, &GMainWindow::EmulationStarting, render_window, &GRenderWindow::OnEmulationStarting); @@ -419,8 +428,6 @@ void GMainWindow::ConnectMenuEvents() { // File connect(ui.action_Load_File, &QAction::triggered, this, &GMainWindow::OnMenuLoadFile); connect(ui.action_Install_CIA, &QAction::triggered, this, &GMainWindow::OnMenuInstallCIA); - connect(ui.action_Select_Game_List_Root, &QAction::triggered, this, - &GMainWindow::OnMenuSelectGameListRoot); connect(ui.action_Exit, &QAction::triggered, this, &QMainWindow::close); // Emulation @@ -672,6 +679,7 @@ void GMainWindow::BootGame(const QString& filename) { registersWidget->OnDebugModeEntered(); if (ui.action_Single_Window_Mode->isChecked()) { game_list->hide(); + game_list_placeholder->hide(); } status_bar_update_timer.start(2000); @@ -713,7 +721,10 @@ void GMainWindow::ShutdownGame() { ui.action_Stop->setEnabled(false); ui.action_Report_Compatibility->setEnabled(false); render_window->hide(); - game_list->show(); + if (game_list->isEmpty()) + game_list_placeholder->show(); + else + game_list->show(); game_list->setFilterFocus(); // Disable status bar updates @@ -828,6 +839,48 @@ void GMainWindow::OnGameListNavigateToGamedbEntry( QDesktopServices::openUrl(QUrl("https://citra-emu.org/game/" + directory)); } +void GMainWindow::OnGameListOpenDirectory(QString directory) { + QString path; + if (directory == "INSTALLED") { + path = + QString::fromStdString(FileUtil::GetUserPath(D_SDMC_IDX).c_str() + + std::string("Nintendo " + "3DS/00000000000000000000000000000000/" + "00000000000000000000000000000000/title/00040000")); + } else if (directory == "SYSTEM") { + path = + QString::fromStdString(FileUtil::GetUserPath(D_NAND_IDX).c_str() + + std::string("00000000000000000000000000000000/title/00040010")); + } else { + path = directory; + } + if (!QFileInfo::exists(path)) { + QMessageBox::critical(this, tr("Error Opening %1").arg(path), tr("Folder does not exist!")); + return; + } + QDesktopServices::openUrl(QUrl::fromLocalFile(path)); +} + +void GMainWindow::OnGameListAddDirectory() { + QString dir_path = QFileDialog::getExistingDirectory(this, tr("Select Directory")); + if (dir_path.isEmpty()) + return; + UISettings::GameDir game_dir{dir_path, false, true}; + if (!UISettings::values.game_dirs.contains(game_dir)) { + UISettings::values.game_dirs.append(game_dir); + game_list->PopulateAsync(UISettings::values.game_dirs); + } else { + NGLOG_WARNING(Frontend, "Selected directory is already in the game list"); + } +} + +void GMainWindow::OnGameListShowList(bool show) { + if (emulation_running && ui.action_Single_Window_Mode->isChecked()) + return; + game_list->setVisible(show); + game_list_placeholder->setVisible(!show); +}; + void GMainWindow::OnMenuLoadFile() { QString extensions; for (const auto& piece : game_list->supported_file_extensions) @@ -845,14 +898,6 @@ void GMainWindow::OnMenuLoadFile() { } } -void GMainWindow::OnMenuSelectGameListRoot() { - QString dir_path = QFileDialog::getExistingDirectory(this, tr("Select Directory")); - if (!dir_path.isEmpty()) { - UISettings::values.gamedir = dir_path; - game_list->PopulateAsync(dir_path, UISettings::values.gamedir_deepscan); - } -} - void GMainWindow::OnMenuInstallCIA() { QStringList filepaths = QFileDialog::getOpenFileNames( this, tr("Load Files"), UISettings::values.roms_path, @@ -1089,6 +1134,7 @@ void GMainWindow::OnConfigure() { if (result == QDialog::Accepted) { configureDialog.applyConfiguration(); UpdateUITheme(); + emit UpdateThemedIcons(); SyncMenuUISettings(); config->Save(); } @@ -1308,7 +1354,6 @@ void GMainWindow::UpdateUITheme() { QIcon::setThemeName(":/icons/default"); } QIcon::setThemeSearchPaths(theme_paths); - emit UpdateThemedIcons(); } void GMainWindow::LoadTranslation() { diff --git a/src/citra_qt/main.h b/src/citra_qt/main.h index 4ec33cc0d..edb893168 100644 --- a/src/citra_qt/main.h +++ b/src/citra_qt/main.h @@ -20,6 +20,7 @@ class ClickableLabel; class EmuThread; class GameList; enum class GameListOpenTarget; +class GameListPlaceholder; class GImageInfo; class GPUCommandListWidget; class GPUCommandStreamWidget; @@ -148,13 +149,14 @@ private slots: void OnGameListNavigateToGamedbEntry( u64 program_id, std::unordered_map>& compatibility_list); + void OnGameListOpenDirectory(QString path); + void OnGameListAddDirectory(); + void OnGameListShowList(bool show); void OnMenuLoadFile(); void OnMenuInstallCIA(); void OnUpdateProgress(size_t written, size_t total); void OnCIAInstallReport(Service::AM::InstallStatus status, QString filepath); void OnCIAInstallFinished(); - /// Called whenever a user selects the "File->Select Game List Root" menu item - void OnMenuSelectGameListRoot(); void OnMenuRecentFile(); void OnConfigure(); void OnToggleFilterBar(); @@ -184,6 +186,8 @@ private: GRenderWindow* render_window; + GameListPlaceholder* game_list_placeholder; + // Status bar elements QProgressBar* progress_bar = nullptr; QLabel* message_label = nullptr; diff --git a/src/citra_qt/main.ui b/src/citra_qt/main.ui index 86c4e46ed..9b0c512e3 100644 --- a/src/citra_qt/main.ui +++ b/src/citra_qt/main.ui @@ -60,7 +60,6 @@ - diff --git a/src/citra_qt/ui_settings.h b/src/citra_qt/ui_settings.h index 0b084eab6..d8d1d7019 100644 --- a/src/citra_qt/ui_settings.h +++ b/src/citra_qt/ui_settings.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,18 @@ static const std::array, 2> themes = { {std::make_pair(QString("Default"), QString("default")), std::make_pair(QString("Dark"), QString("qdarkstyle"))}}; +struct GameDir { + QString path; + bool deep_scan; + bool expanded; + bool operator==(const GameDir& rhs) const { + return path == rhs.path; + }; + bool operator!=(const GameDir& rhs) const { + return !operator==(rhs); + }; +}; + struct Values { QByteArray geometry; QByteArray state; @@ -45,8 +58,9 @@ struct Values { QString roms_path; QString symbols_path; - QString gamedir; - bool gamedir_deepscan; + QString game_dir_deprecated; + bool game_dir_deprecated_deepscan; + QList game_dirs; QStringList recent_files; QString language; @@ -74,3 +88,5 @@ struct Values { extern Values values; } // namespace UISettings + +Q_DECLARE_METATYPE(UISettings::GameDir*);