diff options
Diffstat (limited to 'launcher/ui')
24 files changed, 1716 insertions, 20 deletions
diff --git a/launcher/ui/dialogs/AboutDialog.cpp b/launcher/ui/dialogs/AboutDialog.cpp index 46d2f429..ef96cc23 100644 --- a/launcher/ui/dialogs/AboutDialog.cpp +++ b/launcher/ui/dialogs/AboutDialog.cpp @@ -32,8 +32,14 @@ QString getCreditsHtml() QTextStream stream(&output); stream.setCodec(QTextCodec::codecForName("UTF-8")); stream << "<center>\n"; + + stream << "<h3>" << QObject::tr("PolyMC Developers", "About Credits") << "</h3>\n"; + stream << "<p>swirl <<a href='mailto:swurl@swurl.xyz'>swurl@swurl.xyz </a>></p>\n"; + stream << "<p>LennyMcLennington <<a href='mailto:lenny@sneed.church'>lenny@sneed.church</a>></p>\n"; + stream << "<br />\n"; + // TODO: possibly retrieve from git history at build time? - stream << "<h3>" << QObject::tr("Developers", "About Credits") << "</h3>\n"; + stream << "<h3>" << QObject::tr("MultiMC Developers", "About Credits") << "</h3>\n"; stream << "<p>Andrew Okin <<a href='mailto:forkk@forkk.net'>forkk@forkk.net</a>></p>\n"; stream << "<p>Petr Mrázek <<a href='mailto:peterix@gmail.com'>peterix@gmail.com</a>></p>\n"; stream << "<p>Sky Welch <<a href='mailto:multimc@bunnies.io'>multimc@bunnies.io</a>></p>\n"; @@ -47,6 +53,7 @@ QString getCreditsHtml() stream << "<p>Kilobyte <<a href='mailto:stiepen22@gmx.de'>stiepen22@gmx.de</a>></p>\n"; stream << "<p>Rootbear75 <<a href='https://twitter.com/rootbear75'>@rootbear75</a>></p>\n"; stream << "<p>Zeker Zhayard <<a href='https://twitter.com/zeker_zhayard'>@Zeker_Zhayard</a>></p>\n"; + stream << "<p>Everyone else who <a href='https://github.com/PolyMC/PolyMC/graphs/contributors'>contributed</a>!</p>\n"; stream << "<br />\n"; stream << "</center>\n"; @@ -83,8 +90,12 @@ AboutDialog::AboutDialog(QWidget *parent) : QDialog(parent), ui(new Ui::AboutDia ui->icon->setPixmap(APPLICATION->getThemedIcon("logo").pixmap(64)); ui->title->setText(launcherName); - ui->versionLabel->setText(tr("Version") +": " + BuildConfig.printableVersionString()); - ui->platformLabel->setText(tr("Platform") +": " + BuildConfig.BUILD_PLATFORM); + ui->versionLabel->setText(BuildConfig.printableVersionString()); + + if (!BuildConfig.BUILD_PLATFORM.isEmpty()) + ui->platformLabel->setText(tr("Platform") +": " + BuildConfig.BUILD_PLATFORM); + else + ui->platformLabel->setVisible(false); if (BuildConfig.VERSION_BUILD >= 0) ui->buildNumLabel->setText(tr("Build Number") +": " + QString::number(BuildConfig.VERSION_BUILD)); diff --git a/launcher/ui/dialogs/AboutDialog.ui b/launcher/ui/dialogs/AboutDialog.ui index 822c6f58..58275c66 100644 --- a/launcher/ui/dialogs/AboutDialog.ui +++ b/launcher/ui/dialogs/AboutDialog.ui @@ -87,6 +87,13 @@ </property> </widget> </item> + <item> + <widget class="QLabel" name="versionLabel"> + <property name="alignment"> + <set>Qt::AlignCenter</set> + </property> + </widget> + </item> <item> <widget class="QTabWidget" name="tabWidget"> <property name="currentIndex"> @@ -152,16 +159,6 @@ </widget> </item> <item> - <widget class="QLabel" name="versionLabel"> - <property name="text"> - <string>Version:</string> - </property> - <property name="alignment"> - <set>Qt::AlignCenter</set> - </property> - </widget> - </item> - <item> <widget class="QLabel" name="platformLabel"> <property name="text"> <string>Platform:</string> diff --git a/launcher/ui/dialogs/ModDownloadDialog.cpp b/launcher/ui/dialogs/ModDownloadDialog.cpp new file mode 100644 index 00000000..6b807b8c --- /dev/null +++ b/launcher/ui/dialogs/ModDownloadDialog.cpp @@ -0,0 +1,98 @@ +#include "ModDownloadDialog.h" + +#include <BaseVersion.h> +#include <icons/IconList.h> +#include <InstanceList.h> + +#include "ProgressDialog.h" + +#include <QLayout> +#include <QPushButton> +#include <QValidator> +#include <QDialogButtonBox> + +#include "ui/widgets/PageContainer.h" +#include "ui/pages/modplatform/modrinth/ModrinthPage.h" +#include "ModDownloadTask.h" + + +ModDownloadDialog::ModDownloadDialog(const std::shared_ptr<ModFolderModel> &mods, QWidget *parent, + BaseInstance *instance) + : QDialog(parent), mods(mods), m_instance(instance) +{ + setObjectName(QStringLiteral("ModDownloadDialog")); + resize(400, 347); + m_verticalLayout = new QVBoxLayout(this); + m_verticalLayout->setObjectName(QStringLiteral("verticalLayout")); + + setWindowIcon(APPLICATION->getThemedIcon("new")); + // NOTE: m_buttons must be initialized before PageContainer, because it indirectly accesses m_buttons through setSuggestedPack! Do not move this below. + m_buttons = new QDialogButtonBox(QDialogButtonBox::Help | QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + + m_container = new PageContainer(this); + m_container->setSizePolicy(QSizePolicy::Policy::Preferred, QSizePolicy::Policy::Expanding); + m_container->layout()->setContentsMargins(0, 0, 0, 0); + m_verticalLayout->addWidget(m_container); + + m_container->addButtons(m_buttons); + + // Bonk Qt over its stupid head and make sure it understands which button is the default one... + // See: https://stackoverflow.com/questions/24556831/qbuttonbox-set-default-button + auto OkButton = m_buttons->button(QDialogButtonBox::Ok); + OkButton->setDefault(true); + OkButton->setAutoDefault(true); + connect(OkButton, &QPushButton::clicked, this, &ModDownloadDialog::accept); + + auto CancelButton = m_buttons->button(QDialogButtonBox::Cancel); + CancelButton->setDefault(false); + CancelButton->setAutoDefault(false); + connect(CancelButton, &QPushButton::clicked, this, &ModDownloadDialog::reject); + + auto HelpButton = m_buttons->button(QDialogButtonBox::Help); + HelpButton->setDefault(false); + HelpButton->setAutoDefault(false); + connect(HelpButton, &QPushButton::clicked, m_container, &PageContainer::help); + QMetaObject::connectSlotsByName(this); + setWindowModality(Qt::WindowModal); + setWindowTitle("Download mods"); +} + +QString ModDownloadDialog::dialogTitle() +{ + return tr("Download mods"); +} + +void ModDownloadDialog::reject() +{ + QDialog::reject(); +} + +void ModDownloadDialog::accept() +{ + QDialog::accept(); +} + +QList<BasePage *> ModDownloadDialog::getPages() +{ + modrinthPage = new ModrinthPage(this, m_instance); + flameModPage = new FlameModPage(this, m_instance); + return + { + modrinthPage, + flameModPage + }; +} + +void ModDownloadDialog::setSuggestedMod(const QString& name, ModDownloadTask* task) +{ + modTask.reset(task); + m_buttons->button(QDialogButtonBox::Ok)->setEnabled(task); +} + +ModDownloadDialog::~ModDownloadDialog() +{ +} + +ModDownloadTask *ModDownloadDialog::getTask() { + return modTask.release(); +} diff --git a/launcher/ui/dialogs/ModDownloadDialog.h b/launcher/ui/dialogs/ModDownloadDialog.h new file mode 100644 index 00000000..ece8e328 --- /dev/null +++ b/launcher/ui/dialogs/ModDownloadDialog.h @@ -0,0 +1,54 @@ +#pragma once + +#include <QDialog> +#include <QVBoxLayout> + +#include "BaseVersion.h" +#include "ui/pages/BasePageProvider.h" +#include "minecraft/mod/ModFolderModel.h" +#include "ModDownloadTask.h" +#include "ui/pages/modplatform/flame/FlameModPage.h" + +namespace Ui +{ +class ModDownloadDialog; +} + +class PageContainer; +class QDialogButtonBox; +class ModrinthPage; + +class ModDownloadDialog : public QDialog, public BasePageProvider +{ + Q_OBJECT + +public: + explicit ModDownloadDialog(const std::shared_ptr<ModFolderModel> &mods, QWidget *parent, BaseInstance *instance); + ~ModDownloadDialog(); + + QString dialogTitle() override; + QList<BasePage *> getPages() override; + + void setSuggestedMod(const QString & name = QString(), ModDownloadTask * task = nullptr); + + ModDownloadTask * getTask(); + const std::shared_ptr<ModFolderModel> &mods; + +public slots: + void accept() override; + void reject() override; + +//private slots: + +private: + Ui::ModDownloadDialog *ui = nullptr; + PageContainer * m_container = nullptr; + QDialogButtonBox * m_buttons = nullptr; + QVBoxLayout *m_verticalLayout = nullptr; + + + ModrinthPage *modrinthPage = nullptr; + FlameModPage *flameModPage = nullptr; + std::unique_ptr<ModDownloadTask> modTask; + BaseInstance *m_instance; +}; diff --git a/launcher/ui/pages/global/MinecraftPage.cpp b/launcher/ui/pages/global/MinecraftPage.cpp index c763f8ac..5470a586 100644 --- a/launcher/ui/pages/global/MinecraftPage.cpp +++ b/launcher/ui/pages/global/MinecraftPage.cpp @@ -71,6 +71,9 @@ void MinecraftPage::applySettings() s->set("ShowGameTime", ui->showGameTime->isChecked()); s->set("ShowGlobalGameTime", ui->showGlobalGameTime->isChecked()); s->set("RecordGameTime", ui->recordGameTime->isChecked()); + + // Miscellaneous + s->set("CloseAfterLaunch", ui->closeAfterLaunchCheck->isChecked()); } void MinecraftPage::loadSettings() @@ -88,4 +91,6 @@ void MinecraftPage::loadSettings() ui->showGameTime->setChecked(s->get("ShowGameTime").toBool()); ui->showGlobalGameTime->setChecked(s->get("ShowGlobalGameTime").toBool()); ui->recordGameTime->setChecked(s->get("RecordGameTime").toBool()); + + ui->closeAfterLaunchCheck->setChecked(s->get("CloseAfterLaunch").toBool()); } diff --git a/launcher/ui/pages/global/MinecraftPage.ui b/launcher/ui/pages/global/MinecraftPage.ui index 857b8cfb..a28b1f59 100644 --- a/launcher/ui/pages/global/MinecraftPage.ui +++ b/launcher/ui/pages/global/MinecraftPage.ui @@ -165,6 +165,25 @@ </widget> </item> <item> + <widget class="QGroupBox" name="groupBox"> + <property name="title"> + <string>Miscellaneous</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QCheckBox" name="closeAfterLaunchCheck"> + <property name="toolTip"> + <string><html><head/><body><p>PolyMC will automatically reopen when the game crashes or exits.</p></body></html></string> + </property> + <property name="text"> + <string>Close PolyMC after game window opens</string> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> <spacer name="verticalSpacerMinecraft"> <property name="orientation"> <enum>Qt::Vertical</enum> @@ -184,7 +203,6 @@ </layout> </widget> <tabstops> - <tabstop>tabWidget</tabstop> <tabstop>maximizedCheckBox</tabstop> <tabstop>windowWidthSpinBox</tabstop> <tabstop>windowHeightSpinBox</tabstop> diff --git a/launcher/ui/pages/instance/ModFolderPage.cpp b/launcher/ui/pages/instance/ModFolderPage.cpp index e63b1434..494d32f0 100644 --- a/launcher/ui/pages/instance/ModFolderPage.cpp +++ b/launcher/ui/pages/instance/ModFolderPage.cpp @@ -26,6 +26,7 @@ #include "Application.h" #include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ModDownloadDialog.h" #include "ui/GuiUtil.h" #include "DesktopServices.h" @@ -36,6 +37,7 @@ #include "minecraft/PackProfile.h" #include "Version.h" +#include "ui/dialogs/ProgressDialog.h" namespace { // FIXME: wasteful @@ -141,6 +143,11 @@ ModFolderPage::ModFolderPage( ui(new Ui::ModFolderPage) { ui->setupUi(this); + if(id == "mods") { + auto act = new QAction(tr("Install Mods"), this); + ui->actionsToolbar->insertActionBefore(ui->actionView_configs,act); + connect(act, &QAction::triggered, this, &ModFolderPage::on_actionInstall_mods_triggered); + } ui->actionsToolbar->insertSpacer(ui->actionView_configs); m_inst = inst; @@ -342,6 +349,44 @@ void ModFolderPage::on_actionRemove_triggered() m_mods->deleteMods(selection.indexes()); } +void ModFolderPage::on_actionInstall_mods_triggered() +{ + if(!m_controlsEnabled) { + return; + } + if(m_inst->typeName() != "Minecraft"){ + return; //this is a null instance or a legacy instance + } + bool hasFabric = !((MinecraftInstance *)m_inst)->getPackProfile()->getComponentVersion("net.fabricmc.fabric-loader").isEmpty(); + bool hasForge = !((MinecraftInstance *)m_inst)->getPackProfile()->getComponentVersion("net.minecraftforge").isEmpty(); + if (!hasFabric && !hasForge) { + QMessageBox::critical(this,tr("Error"),tr("Please install a mod loader first!")); + return; + } + ModDownloadDialog mdownload(m_mods, this, m_inst); + if(mdownload.exec()) { + ModDownloadTask *task = mdownload.getTask(); + if (task) { + connect(task, &Task::failed, [this, task](QString reason) { + task->deleteLater(); + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); + }); + connect(task, &Task::succeeded, [this, task]() { + QStringList warnings = task->warnings(); + if (warnings.count()) { + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), + QMessageBox::Warning)->show(); + } + task->deleteLater(); + }); + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(task); + m_mods->update(); + } + } +} + void ModFolderPage::on_actionView_configs_triggered() { DesktopServices::openDirectory(m_inst->instanceConfigFolder(), true); diff --git a/launcher/ui/pages/instance/ModFolderPage.h b/launcher/ui/pages/instance/ModFolderPage.h index 8ef7559b..fbda3cd8 100644 --- a/launcher/ui/pages/instance/ModFolderPage.h +++ b/launcher/ui/pages/instance/ModFolderPage.h @@ -102,6 +102,7 @@ slots: void on_actionRemove_triggered(); void on_actionEnable_triggered(); void on_actionDisable_triggered(); + void on_actionInstall_mods_triggered(); void on_actionView_Folder_triggered(); void on_actionView_configs_triggered(); void ShowContextMenu(const QPoint &pos); diff --git a/launcher/ui/pages/instance/ScreenshotsPage.cpp b/launcher/ui/pages/instance/ScreenshotsPage.cpp index f568ef0d..4011d88c 100644 --- a/launcher/ui/pages/instance/ScreenshotsPage.cpp +++ b/launcher/ui/pages/instance/ScreenshotsPage.cpp @@ -250,6 +250,12 @@ bool ScreenshotsPage::eventFilter(QObject *obj, QEvent *evt) return QWidget::eventFilter(obj, evt); } QKeyEvent *keyEvent = static_cast<QKeyEvent *>(evt); + + if (keyEvent->matches(QKeySequence::Copy)) { + on_actionCopy_File_s_triggered(); + return true; + } + switch (keyEvent->key()) { case Qt::Key_Delete: @@ -272,6 +278,11 @@ ScreenshotsPage::~ScreenshotsPage() void ScreenshotsPage::ShowContextMenu(const QPoint& pos) { auto menu = ui->toolBar->createContextMenu(this, tr("Context menu")); + + if (ui->listView->selectionModel()->selectedRows().size() > 1) { + menu->removeAction( ui->actionCopy_Image ); + } + menu->exec(ui->listView->mapToGlobal(pos)); delete menu; } @@ -377,6 +388,42 @@ void ScreenshotsPage::on_actionUpload_triggered() m_uploadActive = false; } +void ScreenshotsPage::on_actionCopy_Image_triggered() +{ + auto selection = ui->listView->selectionModel()->selectedRows(); + if(selection.size() < 1) + { + return; + } + + // You can only copy one image to the clipboard. In the case of multiple selected files, only the first one gets copied. + auto item = selection[0]; + auto info = m_model->fileInfo(item); + QImage image(info.absoluteFilePath()); + Q_ASSERT(!image.isNull()); + QApplication::clipboard()->setImage(image, QClipboard::Clipboard); +} + +void ScreenshotsPage::on_actionCopy_File_s_triggered() +{ + auto selection = ui->listView->selectionModel()->selectedRows(); + if(selection.size() < 1) + { + // Don't do anything so we don't empty the users clipboard + return; + } + + QString buf = ""; + for (auto item : selection) + { + auto info = m_model->fileInfo(item); + buf += "file:///" + info.absoluteFilePath() + "\r\n"; + } + QMimeData* mimeData = new QMimeData(); + mimeData->setData("text/uri-list", buf.toLocal8Bit()); + QApplication::clipboard()->setMimeData(mimeData); +} + void ScreenshotsPage::on_actionDelete_triggered() { auto mbox = CustomMessageBox::selectable( diff --git a/launcher/ui/pages/instance/ScreenshotsPage.h b/launcher/ui/pages/instance/ScreenshotsPage.h index d2f44837..2a1fdeee 100644 --- a/launcher/ui/pages/instance/ScreenshotsPage.h +++ b/launcher/ui/pages/instance/ScreenshotsPage.h @@ -73,6 +73,8 @@ protected: private slots: void on_actionUpload_triggered(); + void on_actionCopy_Image_triggered(); + void on_actionCopy_File_s_triggered(); void on_actionDelete_triggered(); void on_actionRename_triggered(); void on_actionView_Folder_triggered(); diff --git a/launcher/ui/pages/instance/ScreenshotsPage.ui b/launcher/ui/pages/instance/ScreenshotsPage.ui index ec461087..2e2227a2 100644 --- a/launcher/ui/pages/instance/ScreenshotsPage.ui +++ b/launcher/ui/pages/instance/ScreenshotsPage.ui @@ -50,6 +50,8 @@ <bool>false</bool> </attribute> <addaction name="actionUpload"/> + <addaction name="actionCopy_Image"/> + <addaction name="actionCopy_File_s"/> <addaction name="actionDelete"/> <addaction name="actionRename"/> <addaction name="actionView_Folder"/> @@ -74,6 +76,22 @@ <string>View Folder</string> </property> </action> + <action name="actionCopy_Image"> + <property name="text"> + <string>Copy Image</string> + </property> + <property name="toolTip"> + <string>Copy Image</string> + </property> + </action> + <action name="actionCopy_File_s"> + <property name="text"> + <string>Copy File(s)</string> + </property> + <property name="toolTip"> + <string>Copy File(s)</string> + </property> + </action> </widget> <customwidgets> <customwidget> diff --git a/launcher/ui/pages/modplatform/flame/FlameModModel.cpp b/launcher/ui/pages/modplatform/flame/FlameModModel.cpp new file mode 100644 index 00000000..2cf83261 --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlameModModel.cpp @@ -0,0 +1,273 @@ +#include "FlameModModel.h" +#include "Application.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "FlameModPage.h" +#include <Json.h> + +#include <MMCStrings.h> +#include <Version.h> + +#include <QtMath> + + +namespace FlameMod { + +ListModel::ListModel(FlameModPage *parent) : QAbstractListModel(parent) +{ +} + +ListModel::~ListModel() +{ +} + +int ListModel::rowCount(const QModelIndex &parent) const +{ + return modpacks.size(); +} + +int ListModel::columnCount(const QModelIndex &parent) const +{ + return 1; +} + +QVariant ListModel::data(const QModelIndex &index, int role) const +{ + int pos = index.row(); + if(pos >= modpacks.size() || pos < 0 || !index.isValid()) + { + return QString("INVALID INDEX %1").arg(pos); + } + + IndexedPack pack = modpacks.at(pos); + if(role == Qt::DisplayRole) + { + return pack.name; + } + else if (role == Qt::ToolTipRole) + { + if(pack.description.length() > 100) + { + //some magic to prevent to long tooltips and replace html linebreaks + QString edit = pack.description.left(97); + edit = edit.left(edit.lastIndexOf("<br>")).left(edit.lastIndexOf(" ")).append("..."); + return edit; + + } + return pack.description; + } + else if(role == Qt::DecorationRole) + { + if(m_logoMap.contains(pack.logoName)) + { + return (m_logoMap.value(pack.logoName)); + } + QIcon icon = APPLICATION->getThemedIcon("screenshot-placeholder"); + ((ListModel *)this)->requestLogo(pack.logoName, pack.logoUrl); + return icon; + } + else if(role == Qt::UserRole) + { + QVariant v; + v.setValue(pack); + return v; + } + + return QVariant(); +} + +void ListModel::logoLoaded(QString logo, QIcon out) +{ + m_loadingLogos.removeAll(logo); + m_logoMap.insert(logo, out); + for(int i = 0; i < modpacks.size(); i++) { + if(modpacks[i].logoName == logo) { + emit dataChanged(createIndex(i, 0), createIndex(i, 0), {Qt::DecorationRole}); + } + } +} + +void ListModel::logoFailed(QString logo) +{ + m_failedLogos.append(logo); + m_loadingLogos.removeAll(logo); +} + +void ListModel::requestLogo(QString logo, QString url) +{ + if(m_loadingLogos.contains(logo) || m_failedLogos.contains(logo)) + { + return; + } + + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("FlameMods", QString("logos/%1").arg(logo.section(".", 0, 0))); + auto job = new NetJob(QString("Flame Icon Download %1").arg(logo), APPLICATION->network()); + job->addNetAction(Net::Download::makeCached(QUrl(url), entry)); + + auto fullPath = entry->getFullPath(); + QObject::connect(job, &NetJob::succeeded, this, [this, logo, fullPath, job] + { + job->deleteLater(); + emit logoLoaded(logo, QIcon(fullPath)); + if(waitingCallbacks.contains(logo)) + { + waitingCallbacks.value(logo)(fullPath); + } + }); + + QObject::connect(job, &NetJob::failed, this, [this, logo, job] + { + job->deleteLater(); + emit logoFailed(logo); + }); + + job->start(); + m_loadingLogos.append(logo); +} + +void ListModel::getLogo(const QString &logo, const QString &logoUrl, LogoCallback callback) +{ + if(m_logoMap.contains(logo)) + { + callback(APPLICATION->metacache()->resolveEntry("FlameMods", QString("logos/%1").arg(logo.section(".", 0, 0)))->getFullPath()); + } + else + { + requestLogo(logo, logoUrl); + } +} + +Qt::ItemFlags ListModel::flags(const QModelIndex &index) const +{ + return QAbstractListModel::flags(index); +} + +bool ListModel::canFetchMore(const QModelIndex& parent) const +{ + return searchState == CanPossiblyFetchMore; +} + +void ListModel::fetchMore(const QModelIndex& parent) +{ + if (parent.isValid()) + return; + if(nextSearchOffset == 0) { + qWarning() << "fetchMore with 0 offset is wrong..."; + return; + } + performPaginatedSearch(); +} +const char* sorts[6]{"Featured","Popularity","LastUpdated","Name","Author","TotalDownloads"}; + +void ListModel::performPaginatedSearch() +{ + + QString mcVersion = ((MinecraftInstance *)((FlameModPage *)parent())->m_instance)->getPackProfile()->getComponentVersion("net.minecraft"); + bool hasFabric = !((MinecraftInstance *)((FlameModPage *)parent())->m_instance)->getPackProfile()->getComponentVersion("net.fabricmc.fabric-loader").isEmpty(); + auto netJob = new NetJob("Flame::Search", APPLICATION->network()); + auto searchUrl = QString( + "https://addons-ecs.forgesvc.net/api/v2/addon/search?" + "gameId=432&" + "categoryId=0&" + "sectionId=6&" + + "index=%1&" + "pageSize=25&" + "searchFilter=%2&" + "sort=%3&" + "%4" + "gameVersion=%5" + ) + .arg(nextSearchOffset) + .arg(currentSearchTerm) + .arg(sorts[currentSort]) + .arg(hasFabric ? "modLoaderType=4&" : "") + .arg(mcVersion); + + netJob->addNetAction(Net::Download::makeByteArray(QUrl(searchUrl), &response)); + jobPtr = netJob; + jobPtr->start(); + QObject::connect(netJob, &NetJob::succeeded, this, &ListModel::searchRequestFinished); + QObject::connect(netJob, &NetJob::failed, this, &ListModel::searchRequestFailed); +} + +void ListModel::searchWithTerm(const QString &term, const int sort) +{ + if(currentSearchTerm == term && currentSearchTerm.isNull() == term.isNull() && currentSort == sort) { + return; + } + currentSearchTerm = term; + currentSort = sort; + if(jobPtr) { + jobPtr->abort(); + searchState = ResetRequested; + return; + } + else { + beginResetModel(); + modpacks.clear(); + endResetModel(); + searchState = None; + } + nextSearchOffset = 0; + performPaginatedSearch(); +} + +void ListModel::searchRequestFinished() +{ + jobPtr.reset(); + + QJsonParseError parse_error; + QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error); + if(parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Flame at " << parse_error.offset << " reason: " << parse_error.errorString(); + qWarning() << response; + return; + } + + QList<FlameMod::IndexedPack> newList; + auto packs = doc.array(); + for(auto packRaw : packs) { + auto packObj = packRaw.toObject(); + + FlameMod::IndexedPack pack; + try + { + FlameMod::loadIndexedPack(pack, packObj); + newList.append(pack); + } + catch(const JSONValidationError &e) + { + qWarning() << "Error while loading mod from Flame: " << e.cause(); + continue; + } + } + if(packs.size() < 25) { + searchState = Finished; + } else { + nextSearchOffset += 25; + searchState = CanPossiblyFetchMore; + } + beginInsertRows(QModelIndex(), modpacks.size(), modpacks.size() + newList.size() - 1); + modpacks.append(newList); + endInsertRows(); +} + +void ListModel::searchRequestFailed(QString reason) +{ + jobPtr.reset(); + + if(searchState == ResetRequested) { + beginResetModel(); + modpacks.clear(); + endResetModel(); + + nextSearchOffset = 0; + performPaginatedSearch(); + } else { + searchState = Finished; + } +} + +} + diff --git a/launcher/ui/pages/modplatform/flame/FlameModModel.h b/launcher/ui/pages/modplatform/flame/FlameModModel.h new file mode 100644 index 00000000..0c1cb95e --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlameModModel.h @@ -0,0 +1,79 @@ +#pragma once + +#include <RWStorage.h> + +#include <QAbstractListModel> +#include <QSortFilterProxyModel> +#include <QThreadPool> +#include <QIcon> +#include <QStyledItemDelegate> +#include <QList> +#include <QString> +#include <QStringList> +#include <QMetaType> + +#include <functional> +#include <net/NetJob.h> + +#include <modplatform/flame/FlamePackIndex.h> +#include "modplatform/flame/FlameModIndex.h" +#include "BaseInstance.h" +#include "FlameModPage.h" + +namespace FlameMod { + + +typedef QMap<QString, QIcon> LogoMap; +typedef std::function<void(QString)> LogoCallback; + +class ListModel : public QAbstractListModel +{ + Q_OBJECT + +public: + ListModel(FlameModPage *parent); + virtual ~ListModel(); + + int rowCount(const QModelIndex &parent) const override; + int columnCount(const QModelIndex &parent) const override; + QVariant data(const QModelIndex &index, int role) const override; + Qt::ItemFlags flags(const QModelIndex &index) const override; + bool canFetchMore(const QModelIndex & parent) const override; + void fetchMore(const QModelIndex & parent) override; + + void getLogo(const QString &logo, const QString &logoUrl, LogoCallback callback); + void searchWithTerm(const QString &term, const int sort); + +private slots: + void performPaginatedSearch(); + + void logoFailed(QString logo); + void logoLoaded(QString logo, QIcon out); + + void searchRequestFinished(); + void searchRequestFailed(QString reason); + +private: + void requestLogo(QString file, QString url); + +private: + QList<IndexedPack> modpacks; + QStringList m_failedLogos; + QStringList m_loadingLogos; + LogoMap m_logoMap; + QMap<QString, LogoCallback> waitingCallbacks; + + QString currentSearchTerm; + int currentSort = 0; + int nextSearchOffset = 0; + enum SearchState { + None, + CanPossiblyFetchMore, + ResetRequested, + Finished + } searchState = None; + NetJob::Ptr jobPtr; + QByteArray response; +}; + +} diff --git a/launcher/ui/pages/modplatform/flame/FlameModPage.cpp b/launcher/ui/pages/modplatform/flame/FlameModPage.cpp new file mode 100644 index 00000000..80f3de19 --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlameModPage.cpp @@ -0,0 +1,194 @@ +#include "FlameModPage.h" +#include "ui_FlameModPage.h" + +#include <QKeyEvent> + +#include "Application.h" +#include "Json.h" +#include "ui/dialogs/ModDownloadDialog.h" +#include "InstanceImportTask.h" +#include "FlameModModel.h" +#include "ModDownloadTask.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +FlameModPage::FlameModPage(ModDownloadDialog *dialog, BaseInstance *instance) + : QWidget(dialog), m_instance(instance), ui(new Ui::FlameModPage), dialog(dialog) +{ + ui->setupUi(this); + connect(ui->searchButton, &QPushButton::clicked, this, &FlameModPage::triggerSearch); + ui->searchEdit->installEventFilter(this); + listModel = new FlameMod::ListModel(this); + ui->packView->setModel(listModel); + + ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300); + + // index is used to set the sorting with the flame api + ui->sortByBox->addItem(tr("Sort by Featured")); + ui->sortByBox->addItem(tr("Sort by Popularity")); + ui->sortByBox->addItem(tr("Sort by last updated")); + ui->sortByBox->addItem(tr("Sort by Name")); + ui->sortByBox->addItem(tr("Sort by Author")); + ui->sortByBox->addItem(tr("Sort by Downloads")); + + connect(ui->sortByBox, SIGNAL(currentIndexChanged(int)), this, SLOT(triggerSearch())); + connect(ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &FlameModPage::onSelectionChanged); + connect(ui->versionSelectionBox, &QComboBox::currentTextChanged, this, &FlameModPage::onVersionSelectionChanged); +} + +FlameModPage::~FlameModPage() +{ + delete ui; +} + +bool FlameModPage::eventFilter(QObject* watched, QEvent* event) +{ + if (watched == ui->searchEdit && event->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast<QKeyEvent*>(event); + if (keyEvent->key() == Qt::Key_Return) { + triggerSearch(); + keyEvent->accept(); + return true; + } + } + return QWidget::eventFilter(watched, event); +} + +bool FlameModPage::shouldDisplay() const +{ + return true; +} + +void FlameModPage::openedImpl() +{ + suggestCurrent(); + triggerSearch(); +} + +void FlameModPage::triggerSearch() +{ + listModel->searchWithTerm(ui->searchEdit->text(), ui->sortByBox->currentIndex()); +} + +void FlameModPage::onSelectionChanged(QModelIndex first, QModelIndex second) +{ + ui->versionSelectionBox->clear(); + + if(!first.isValid()) + { + if(isOpened) + { + dialog->setSuggestedMod(); + } + return; + } + + current = listModel->data(first, Qt::UserRole).value<FlameMod::IndexedPack>(); + QString text = ""; + QString name = current.name; + + if (current.websiteUrl.isEmpty()) + text = name; + else + text = "<a href=\"" + current.websiteUrl + "\">" + name + "</a>"; + if (!current.authors.empty()) { + auto authorToStr = [](FlameMod::ModpackAuthor & author) { + if(author.url.isEmpty()) { + return author.name; + } + return QString("<a href=\"%1\">%2</a>").arg(author.url, author.name); + }; + QStringList authorStrs; + for(auto & author: current.authors) { + authorStrs.push_back(authorToStr(author)); + } + text += "<br>" + tr(" by ") + authorStrs.join(", "); + } + text += "<br><br>"; + + ui->packDescription->setHtml(text + current.description); + + if (!current.versionsLoaded) + { + qDebug() << "Loading flame mod versions"; + auto netJob = new NetJob(QString("Flame::ModVersions(%1)").arg(current.name), APPLICATION->network()); + std::shared_ptr<QByteArray> response = std::make_shared<QByteArray>(); + int addonId = current.addonId; + netJob->addNetAction(Net::Download::makeByteArray(QString("https://addons-ecs.forgesvc.net/api/v2/addon/%1/files").arg(addonId), response.get())); + + QObject::connect(netJob, &NetJob::succeeded, this, [this, response, netJob] + { + netJob->deleteLater(); + QJsonParseError parse_error; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if(parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Flame at " << parse_error.offset << " reason: " << parse_error.errorString(); + qWarning() << *response; + return; + } + QJsonArray arr = doc.array(); + try + { + FlameMod::loadIndexedPackVersions(current, arr, APPLICATION->network(), m_instance); + } + catch(const JSONValidationError &e) + { + qDebug() << *response; + qWarning() << "Error while reading Flame mod version: " << e.cause(); + } + auto packProfile = ((MinecraftInstance *)m_instance)->getPackProfile(); + QString mcVersion = packProfile->getComponentVersion("net.minecraft"); + QString loaderString = (packProfile->getComponentVersion("net.minecraftforge").isEmpty()) ? "fabric" : "forge"; + for(const auto& version : current.versions) { + if(!version.mcVersion.contains(mcVersion)){ + continue; + } + ui->versionSelectionBox->addItem(version.version, QVariant(version.downloadUrl)); + } + if(ui->versionSelectionBox->count() == 0){ + ui->versionSelectionBox->addItem(tr("No Valid Version found!"), QVariant("")); + } + + suggestCurrent(); + }); + netJob->start(); + } + else + { + for(auto version : current.versions) { + ui->versionSelectionBox->addItem(version.version, QVariant(version.downloadUrl)); + } + if(ui->versionSelectionBox->count() == 0){ + ui->versionSelectionBox->addItem(tr("No Valid Version found!"), QVariant("")); + } + suggestCurrent(); + } +} + +void FlameModPage::suggestCurrent() +{ + if(!isOpened) + { + return; + } + + if (selectedVersion.isEmpty()) + { + dialog->setSuggestedMod(); + return; + } + + dialog->setSuggestedMod(current.name, new ModDownloadTask(selectedVersion, current.versions.at(0).fileName ,dialog->mods)); +} + +void FlameModPage::onVersionSelectionChanged(QString data) +{ + if(data.isNull() || data.isEmpty()) + { + selectedVersion = ""; + return; + } + selectedVersion = ui->versionSelectionBox->currentData().toString(); + suggestCurrent(); +} diff --git a/launcher/ui/pages/modplatform/flame/FlameModPage.h b/launcher/ui/pages/modplatform/flame/FlameModPage.h new file mode 100644 index 00000000..85c68620 --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlameModPage.h @@ -0,0 +1,67 @@ +#pragma once + +#include <QWidget> + +#include "ui/pages/BasePage.h" +#include <Application.h> +#include "tasks/Task.h" +#include "modplatform/flame/FlameModIndex.h" + +namespace Ui +{ +class FlameModPage; +} + +class ModDownloadDialog; + +namespace FlameMod { + class ListModel; +} + +class FlameModPage : public QWidget, public BasePage +{ + Q_OBJECT + +public: + explicit FlameModPage(ModDownloadDialog *dialog, BaseInstance *instance); + virtual ~FlameModPage(); + virtual QString displayName() const override + { + return tr("CurseForge"); + } + virtual QIcon icon() const override + { + return APPLICATION->getThemedIcon("flame"); + } + virtual QString id() const override + { + return "curseforge"; + } + virtual QString helpPage() const override + { + return "Flame-platform"; + } + virtual bool shouldDisplay() const override; + + void openedImpl() override; + + bool eventFilter(QObject * watched, QEvent * event) override; + + BaseInstance *m_instance; + +private: + void suggestCurrent(); + +private slots: + void triggerSearch(); + void onSelectionChanged(QModelIndex first, QModelIndex second); + void onVersionSelectionChanged(QString data); + +private: + Ui::FlameModPage *ui = nullptr; + ModDownloadDialog* dialog = nullptr; + FlameMod::ListModel* listModel = nullptr; + FlameMod::IndexedPack current; + + QString selectedVersion; +}; diff --git a/launcher/ui/pages/modplatform/flame/FlameModPage.ui b/launcher/ui/pages/modplatform/flame/FlameModPage.ui new file mode 100644 index 00000000..7da0bb4a --- /dev/null +++ b/launcher/ui/pages/modplatform/flame/FlameModPage.ui @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>FlameModPage</class> + <widget class="QWidget" name="FlameModPage"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>837</width> + <height>685</height> + </rect> + </property> + <layout class="QGridLayout" name="gridLayout"> + <item row="1" column="0" colspan="2"> + <layout class="QGridLayout" name="gridLayout_3"> + <item row="1" column="0"> + <widget class="QListView" name="packView"> + <property name="iconSize"> + <size> + <width>48</width> + <height>48</height> + </size> + </property> + <property name="horizontalScrollBarPolicy"> + <enum>Qt::ScrollBarAlwaysOff</enum> + </property> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QTextBrowser" name="packDescription"> + <property name="openExternalLinks"> + <bool>true</bool> + </property> + <property name="openLinks"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </item> + <item row="2" column="0" colspan="2"> + <layout class="QGridLayout" name="gridLayout_4" columnstretch="0,0,0" rowminimumheight="0" columnminimumwidth="0,0,0"> + <item row="0" column="2"> + <widget class="QComboBox" name="versionSelectionBox"/> + </item> + <item row="0" column="1"> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Version selected:</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item row="0" column="0"> + <widget class="QComboBox" name="sortByBox"/> + </item> + </layout> + </item> + <item row="0" column="1"> + <widget class="QPushButton" name="searchButton"> + <property name="text"> + <string>Search</string> + </property> + </widget> + </item> + <item row="0" column="0"> + <widget class="QLineEdit" name="searchEdit"> + <property name="placeholderText"> + <string>Search and filter ...</string> + </property> + </widget> + </item> + </layout> + </widget> + <tabstops> + <tabstop>searchEdit</tabstop> + <tabstop>searchButton</tabstop> + <tabstop>packView</tabstop> + <tabstop>packDescription</tabstop> + <tabstop>sortByBox</tabstop> + <tabstop>versionSelectionBox</tabstop> + </tabstops> + <resources/> + <connections/> +</ui> diff --git a/launcher/ui/pages/modplatform/flame/FlameModel.cpp b/launcher/ui/pages/modplatform/flame/FlameModel.cpp index 891676cf..fe163cae 100644 --- a/launcher/ui/pages/modplatform/flame/FlameModel.cpp +++ b/launcher/ui/pages/modplatform/flame/FlameModel.cpp @@ -6,9 +6,6 @@ #include <Version.h> #include <QtMath> -#include <QLabel> - -#include <RWStorage.h> namespace Flame { @@ -100,12 +97,13 @@ void ListModel::requestLogo(QString logo, QString url) } MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("FlamePacks", QString("logos/%1").arg(logo.section(".", 0, 0))); - NetJob *job = new NetJob(QString("Flame Icon Download %1").arg(logo), APPLICATION->network()); + auto job = new NetJob(QString("Flame Icon Download %1").arg(logo), APPLICATION->network()); job->addNetAction(Net::Download::makeCached(QUrl(url), entry)); auto fullPath = entry->getFullPath(); - QObject::connect(job, &NetJob::succeeded, this, [this, logo, fullPath] + QObject::connect(job, &NetJob::succeeded, this, [this, logo, fullPath, job] { + job->deleteLater(); emit logoLoaded(logo, QIcon(fullPath)); if(waitingCallbacks.contains(logo)) { @@ -113,8 +111,9 @@ void ListModel::requestLogo(QString logo, QString url) } }); - QObject::connect(job, &NetJob::failed, this, [this, logo] + QObject::connect(job, &NetJob::failed, this, [this, logo, job] { + job->deleteLater(); emit logoFailed(logo); }); diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp new file mode 100644 index 00000000..71574156 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp @@ -0,0 +1,268 @@ +#include "ModrinthModel.h" +#include "Application.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "ModrinthPage.h" +#include <Json.h> + +#include <MMCStrings.h> +#include <Version.h> + +#include <QtMath> + + +namespace Modrinth { + +ListModel::ListModel(ModrinthPage *parent) : QAbstractListModel(parent) +{ +} + +ListModel::~ListModel() +{ +} + +int ListModel::rowCount(const QModelIndex &parent) const +{ + return modpacks.size(); +} + +int ListModel::columnCount(const QModelIndex &parent) const +{ + return 1; +} + +QVariant ListModel::data(const QModelIndex &index, int role) const +{ + int pos = index.row(); + if(pos >= modpacks.size() || pos < 0 || !index.isValid()) + { + return QString("INVALID INDEX %1").arg(pos); + } + + IndexedPack pack = modpacks.at(pos); + if(role == Qt::DisplayRole) + { + return pack.name; + } + else if (role == Qt::ToolTipRole) + { + if(pack.description.length() > 100) + { + //some magic to prevent to long tooltips and replace html linebreaks + QString edit = pack.description.left(97); + edit = edit.left(edit.lastIndexOf("<br>")).left(edit.lastIndexOf(" ")).append("..."); + return edit; + + } + return pack.description; + } + else if(role == Qt::DecorationRole) + { + if(m_logoMap.contains(pack.logoName)) + { + return (m_logoMap.value(pack.logoName)); + } + QIcon icon = APPLICATION->getThemedIcon("screenshot-placeholder"); + ((ListModel *)this)->requestLogo(pack.logoName, pack.logoUrl); + return icon; + } + else if(role == Qt::UserRole) + { + QVariant v; + v.setValue(pack); + return v; + } + + return QVariant(); +} + +void ListModel::logoLoaded(QString logo, QIcon out) +{ + m_loadingLogos.removeAll(logo); + m_logoMap.insert(logo, out); + for(int i = 0; i < modpacks.size(); i++) { + if(modpacks[i].logoName == logo) { + emit dataChanged(createIndex(i, 0), createIndex(i, 0), {Qt::DecorationRole}); + } + } +} + +void ListModel::logoFailed(QString logo) +{ + m_failedLogos.append(logo); + m_loadingLogos.removeAll(logo); +} + +void ListModel::requestLogo(QString logo, QString url) +{ + if(m_loadingLogos.contains(logo) || m_failedLogos.contains(logo)) + { + return; + } + + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("ModrinthPacks", QString("logos/%1").arg(logo.section(".", 0, 0))); + auto job = new NetJob(QString("Modrinth Icon Download %1").arg(logo), APPLICATION->network()); + job->addNetAction(Net::Download::makeCached(QUrl(url), entry)); + + auto fullPath = entry->getFullPath(); + QObject::connect(job, &NetJob::succeeded, this, [this, logo, fullPath, job] + { + job->deleteLater(); + emit logoLoaded(logo, QIcon(fullPath)); + if(waitingCallbacks.contains(logo)) + { + waitingCallbacks.value(logo)(fullPath); + } + }); + + QObject::connect(job, &NetJob::failed, this, [this, logo, job] + { + job->deleteLater(); + emit logoFailed(logo); + }); + + job->start(); + m_loadingLogos.append(logo); +} + +void ListModel::getLogo(const QString &logo, const QString &logoUrl, LogoCallback callback) +{ + if(m_logoMap.contains(logo)) + { + callback(APPLICATION->metacache()->resolveEntry("ModrinthPacks", QString("logos/%1").arg(logo.section(".", 0, 0)))->getFullPath()); + } + else + { + requestLogo(logo, logoUrl); + } +} + +Qt::ItemFlags ListModel::flags(const QModelIndex &index) const +{ + return QAbstractListModel::flags(index); +} + +bool ListModel::canFetchMore(const QModelIndex& parent) const +{ + return searchState == CanPossiblyFetchMore; +} + +void ListModel::fetchMore(const QModelIndex& parent) +{ + if (parent.isValid()) + return; + if(nextSearchOffset == 0) { + qWarning() << "fetchMore with 0 offset is wrong..."; + return; + } + performPaginatedSearch(); +} +const char* sorts[5]{"relevance","downloads","follows","updated","newest"}; + +void ListModel::performPaginatedSearch() +{ + + QString mcVersion = ((MinecraftInstance *)((ModrinthPage *)parent())->m_instance)->getPackProfile()->getComponentVersion("net.minecraft"); + bool hasFabric = !((MinecraftInstance *)((ModrinthPage *)parent())->m_instance)->getPackProfile()->getComponentVersion("net.fabricmc.fabric-loader").isEmpty(); + auto netJob = new NetJob("Modrinth::Search", APPLICATION->network()); + auto searchUrl = QString( + "https://api.modrinth.com/v2/search?" + "offset=%1&" + "limit=25&" + "query=%2&" + "index=%3&" + "facets=[[\"categories:%4\"],[\"versions:%5\"],[\"project_type:mod\"]]" + ) + .arg(nextSearchOffset) + .arg(currentSearchTerm) + .arg(sorts[currentSort]) + .arg(hasFabric ? "fabric" : "forge") + .arg(mcVersion); + + netJob->addNetAction(Net::Download::makeByteArray(QUrl(searchUrl), &response)); + jobPtr = netJob; + jobPtr->start(); + QObject::connect(netJob, &NetJob::succeeded, this, &ListModel::searchRequestFinished); + QObject::connect(netJob, &NetJob::failed, this, &ListModel::searchRequestFailed); +} + +void ListModel::searchWithTerm(const QString &term, const int sort) +{ + if(currentSearchTerm == term && currentSearchTerm.isNull() == term.isNull() && currentSort == sort) { + return; + } + currentSearchTerm = term; + currentSort = sort; + if(jobPtr) { + jobPtr->abort(); + searchState = ResetRequested; + return; + } + else { + beginResetModel(); + modpacks.clear(); + endResetModel(); + searchState = None; + } + nextSearchOffset = 0; + performPaginatedSearch(); +} + +void Modrinth::ListModel::searchRequestFinished() +{ + jobPtr.reset(); + + QJsonParseError parse_error; + QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error); + if(parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Modrinth at " << parse_error.offset << " reason: " << parse_error.errorString(); + qWarning() << response; + return; + } + + QList<Modrinth::IndexedPack> newList; + auto packs = doc.object().value("hits").toArray(); + for(auto packRaw : packs) { + auto packObj = packRaw.toObject(); + + Modrinth::IndexedPack pack; + try + { + Modrinth::loadIndexedPack(pack, packObj); + newList.append(pack); + } + catch(const JSONValidationError &e) + { + qWarning() << "Error while loading mod from Modrinth: " << e.cause(); + continue; + } + } + if(packs.size() < 25) { + searchState = Finished; + } else { + nextSearchOffset += 25; + searchState = CanPossiblyFetchMore; + } + beginInsertRows(QModelIndex(), modpacks.size(), modpacks.size() + newList.size() - 1); + modpacks.append(newList); + endInsertRows(); +} + +void Modrinth::ListModel::searchRequestFailed(QString reason) +{ + jobPtr.reset(); + + if(searchState == ResetRequested) { + beginResetModel(); + modpacks.clear(); + endResetModel(); + + nextSearchOffset = 0; + performPaginatedSearch(); + } else { + searchState = Finished; + } +} + +} + diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h new file mode 100644 index 00000000..53f1f134 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h @@ -0,0 +1,79 @@ +#pragma once + +#include <RWStorage.h> + +#include <QAbstractListModel> +#include <QSortFilterProxyModel> +#include <QThreadPool> +#include <QIcon> +#include <QStyledItemDelegate> +#include <QList> +#include <QString> +#include <QStringList> +#include <QMetaType> + +#include <functional> +#include <net/NetJob.h> + +#include <modplatform/flame/FlamePackIndex.h> +#include "modplatform/modrinth/ModrinthPackIndex.h" +#include "BaseInstance.h" +#include "ModrinthPage.h" + +namespace Modrinth { + + +typedef QMap<QString, QIcon> LogoMap; +typedef std::function<void(QString)> LogoCallback; + +class ListModel : public QAbstractListModel +{ + Q_OBJECT + +public: + ListModel(ModrinthPage *parent); + virtual ~ListModel(); + + int rowCount(const QModelIndex &parent) const override; + int columnCount(const QModelIndex &parent) const override; + QVariant data(const QModelIndex &index, int role) const override; + Qt::ItemFlags flags(const QModelIndex &index) const override; + bool canFetchMore(const QModelIndex & parent) const override; + void fetchMore(const QModelIndex & parent) override; + + void getLogo(const QString &logo, const QString &logoUrl, LogoCallback callback); + void searchWithTerm(const QString &term, const int sort); + +private slots: + void performPaginatedSearch(); + + void logoFailed(QString logo); + void logoLoaded(QString logo, QIcon out); + + void searchRequestFinished(); + void searchRequestFailed(QString reason); + +private: + void requestLogo(QString file, QString url); + +private: + QList<IndexedPack> modpacks; + QStringList m_failedLogos; + QStringList m_loadingLogos; + LogoMap m_logoMap; + QMap<QString, LogoCallback> waitingCallbacks; + + QString currentSearchTerm; + int currentSort = 0; + int nextSearchOffset = 0; + enum SearchState { + None, + CanPossiblyFetchMore, + ResetRequested, + Finished + } searchState = None; + NetJob::Ptr jobPtr; + QByteArray response; +}; + +} diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp new file mode 100644 index 00000000..ee3c9e76 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp @@ -0,0 +1,179 @@ +#include "ModrinthPage.h" +#include "ui_ModrinthPage.h" + +#include <QKeyEvent> + +#include "Application.h" +#include "Json.h" +#include "ui/dialogs/ModDownloadDialog.h" +#include "InstanceImportTask.h" +#include "ModrinthModel.h" +#include "ModDownloadTask.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +ModrinthPage::ModrinthPage(ModDownloadDialog *dialog, BaseInstance *instance) + : QWidget(dialog), m_instance(instance), ui(new Ui::ModrinthPage), dialog(dialog) +{ + ui->setupUi(this); + connect(ui->searchButton, &QPushButton::clicked, this, &ModrinthPage::triggerSearch); + ui->searchEdit->installEventFilter(this); + listModel = new Modrinth::ListModel(this); + ui->packView->setModel(listModel); + + ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300); + + // index is used to set the sorting with the modrinth api + ui->sortByBox->addItem(tr("Sort by Relevence")); + ui->sortByBox->addItem(tr("Sort by Downloads")); + ui->sortByBox->addItem(tr("Sort by Follows")); + ui->sortByBox->addItem(tr("Sort by last updated")); + ui->sortByBox->addItem(tr("Sort by newest")); + + connect(ui->sortByBox, SIGNAL(currentIndexChanged(int)), this, SLOT(triggerSearch())); + connect(ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &ModrinthPage::onSelectionChanged); + connect(ui->versionSelectionBox, &QComboBox::currentTextChanged, this, &ModrinthPage::onVersionSelectionChanged); +} + +ModrinthPage::~ModrinthPage() +{ + delete ui; +} + +bool ModrinthPage::eventFilter(QObject* watched, QEvent* event) +{ + if (watched == ui->searchEdit && event->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast<QKeyEvent*>(event); + if (keyEvent->key() == Qt::Key_Return) { + triggerSearch(); + keyEvent->accept(); + return true; + } + } + return QWidget::eventFilter(watched, event); +} + +bool ModrinthPage::shouldDisplay() const +{ + return true; +} + +void ModrinthPage::openedImpl() +{ + suggestCurrent(); + triggerSearch(); +} + +void ModrinthPage::triggerSearch() +{ + listModel->searchWithTerm(ui->searchEdit->text(), ui->sortByBox->currentIndex()); +} + +void ModrinthPage::onSelectionChanged(QModelIndex first, QModelIndex second) +{ + ui->versionSelectionBox->clear(); + + if(!first.isValid()) + { + if(isOpened) + { + dialog->setSuggestedMod(); + } + return; + } + + current = listModel->data(first, Qt::UserRole).value<Modrinth::IndexedPack>(); + QString text = ""; + QString name = current.name; + + if (current.websiteUrl.isEmpty()) + text = name; + else + text = "<a href=\"" + current.websiteUrl + "\">" + name + "</a>"; + text += "<br>"+ tr(" by ") + "<a href=\""+current.author.url+"\">"+current.author.name+"</a><br><br>"; + ui->packDescription->setHtml(text + current.description); + + if (!current.versionsLoaded) + { + qDebug() << "Loading Modrinth mod versions"; + auto netJob = new NetJob(QString("Modrinth::ModVersions(%1)").arg(current.name), APPLICATION->network()); + std::shared_ptr<QByteArray> response = std::make_shared<QByteArray>(); + QString addonId = current.addonId; + netJob->addNetAction(Net::Download::makeByteArray(QString("https://api.modrinth.com/v2/project/%1/version").arg(addonId), response.get())); + + QObject::connect(netJob, &NetJob::succeeded, this, [this, response, netJob] + { + netJob->deleteLater(); + QJsonParseError parse_error; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if(parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Modrinth at " << parse_error.offset << " reason: " << parse_error.errorString(); + qWarning() << *response; + return; + } + QJsonArray arr = doc.array(); + try + { + Modrinth::loadIndexedPackVersions(current, arr, APPLICATION->network(), m_instance); + } + catch(const JSONValidationError &e) + { + qDebug() << *response; + qWarning() << "Error while reading Modrinth mod version: " << e.cause(); + } + auto packProfile = ((MinecraftInstance *)m_instance)->getPackProfile(); + QString mcVersion = packProfile->getComponentVersion("net.minecraft"); + QString loaderString = (packProfile->getComponentVersion("net.minecraftforge").isEmpty()) ? "fabric" : "forge"; + for(const auto& version : current.versions) { + if(!version.mcVersion.contains(mcVersion) || !version.loaders.contains(loaderString)){ + continue; + } + ui->versionSelectionBox->addItem(version.version, QVariant(version.downloadUrl)); + } + if(ui->versionSelectionBox->count() == 0){ + ui->versionSelectionBox->addItem(tr("No Valid Version found !"), QVariant("")); + } + + suggestCurrent(); + }); + netJob->start(); + } + else + { + for(auto version : current.versions) { + ui->versionSelectionBox->addItem(version.version, QVariant(version.downloadUrl)); + } + if(ui->versionSelectionBox->count() == 0){ + ui->versionSelectionBox->addItem(tr("No Valid Version found !"), QVariant("")); + } + suggestCurrent(); + } +} + +void ModrinthPage::suggestCurrent() +{ + if(!isOpened) + { + return; + } + + if (selectedVersion.isEmpty()) + { + dialog->setSuggestedMod(); + return; + } + + dialog->setSuggestedMod(current.name, new ModDownloadTask(selectedVersion, current.versions.at(0).fileName ,dialog->mods)); +} + +void ModrinthPage::onVersionSelectionChanged(QString data) +{ + if(data.isNull() || data.isEmpty()) + { + selectedVersion = ""; + return; + } + selectedVersion = ui->versionSelectionBox->currentData().toString(); + suggestCurrent(); +} diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h new file mode 100644 index 00000000..3748d836 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h @@ -0,0 +1,67 @@ +#pragma once + +#include <QWidget> + +#include "ui/pages/BasePage.h" +#include <Application.h> +#include "tasks/Task.h" +#include "modplatform/modrinth/ModrinthPackIndex.h" + +namespace Ui +{ +class ModrinthPage; +} + +class ModDownloadDialog; + +namespace Modrinth { + class ListModel; +} + +class ModrinthPage : public QWidget, public BasePage +{ + Q_OBJECT + +public: + explicit ModrinthPage(ModDownloadDialog *dialog, BaseInstance *instance); + virtual ~ModrinthPage(); + virtual QString displayName() const override + { + return tr("Modrinth"); + } + virtual QIcon icon() const override + { + return APPLICATION->getThemedIcon("modrinth"); + } + virtual QString id() const override + { + return "modrinth"; + } + virtual QString helpPage() const override + { + return "Modrinth-platform"; + } + virtual bool shouldDisplay() const override; + + void openedImpl() override; + + bool eventFilter(QObject * watched, QEvent * event) override; + + BaseInstance *m_instance; + +private: + void suggestCurrent(); + +private slots: + void triggerSearch(); + void onSelectionChanged(QModelIndex first, QModelIndex second); + void onVersionSelectionChanged(QString data); + +private: + Ui::ModrinthPage *ui = nullptr; + ModDownloadDialog* dialog = nullptr; + Modrinth::ListModel* listModel = nullptr; + Modrinth::IndexedPack current; + + QString selectedVersion; +}; diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.ui b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.ui new file mode 100644 index 00000000..6d183de5 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.ui @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>ModrinthPage</class> + <widget class="QWidget" name="ModrinthPage"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>837</width> + <height>685</height> + </rect> + </property> + <layout class="QGridLayout" name="gridLayout"> + <item row="1" column="0" colspan="2"> + <layout class="QGridLayout" name="gridLayout_3"> + <item row="1" column="0"> + <widget class="QListView" name="packView"> + <property name="iconSize"> + <size> + <width>48</width> + <height>48</height> + </size> + </property> + <property name="horizontalScrollBarPolicy"> + <enum>Qt::ScrollBarAlwaysOff</enum> + </property> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QTextBrowser" name="packDescription"> + <property name="openExternalLinks"> + <bool>true</bool> + </property> + <property name="openLinks"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </item> + <item row="2" column="0" colspan="2"> + <layout class="QGridLayout" name="gridLayout_4" columnstretch="0,0,0" rowminimumheight="0" columnminimumwidth="0,0,0"> + <item row="0" column="2"> + <widget class="QComboBox" name="versionSelectionBox"/> + </item> + <item row="0" column="1"> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Version selected:</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item row="0" column="0"> + <widget class="QComboBox" name="sortByBox"/> + </item> + </layout> + </item> + <item row="0" column="1"> + <widget class="QPushButton" name="searchButton"> + <property name="text"> + <string>Search</string> + </property> + </widget> + </item> + <item row="0" column="0"> + <widget class="QLineEdit" name="searchEdit"> + <property name="placeholderText"> + <string>Search and filter ...</string> + </property> + </widget> + </item> + </layout> + </widget> + <tabstops> + <tabstop>searchEdit</tabstop> + <tabstop>searchButton</tabstop> + <tabstop>packView</tabstop> + <tabstop>packDescription</tabstop> + <tabstop>sortByBox</tabstop> + <tabstop>versionSelectionBox</tabstop> + </tabstops> + <resources/> + <connections/> +</ui> diff --git a/launcher/ui/widgets/WideBar.cpp b/launcher/ui/widgets/WideBar.cpp index cbd6c617..8d5bd12d 100644 --- a/launcher/ui/widgets/WideBar.cpp +++ b/launcher/ui/widgets/WideBar.cpp @@ -76,6 +76,20 @@ void WideBar::addSeparator() m_entries.push_back(entry); } +void WideBar::insertActionBefore(QAction* before, QAction* action){ + auto iter = std::find_if(m_entries.begin(), m_entries.end(), [before](BarEntry * entry) { + return entry->wideAction == before; + }); + if(iter == m_entries.end()) { + return; + } + auto entry = new BarEntry(); + entry->qAction = insertWidget((*iter)->qAction, new ActionButton(action, this)); + entry->wideAction = action; + entry->type = BarEntry::Action; + m_entries.insert(iter, entry); +} + void WideBar::insertSpacer(QAction* action) { auto iter = std::find_if(m_entries.begin(), m_entries.end(), [action](BarEntry * entry) { diff --git a/launcher/ui/widgets/WideBar.h b/launcher/ui/widgets/WideBar.h index d1b8cbe7..2b676a8c 100644 --- a/launcher/ui/widgets/WideBar.h +++ b/launcher/ui/widgets/WideBar.h @@ -18,6 +18,7 @@ public: void addAction(QAction *action); void addSeparator(); void insertSpacer(QAction *action); + void insertActionBefore(QAction *before, QAction *action); QMenu *createContextMenu(QWidget *parent = nullptr, const QString & title = QString()); private: |