aboutsummaryrefslogtreecommitdiff
path: root/launcher/ui
diff options
context:
space:
mode:
Diffstat (limited to 'launcher/ui')
-rw-r--r--launcher/ui/dialogs/AboutDialog.cpp78
-rw-r--r--launcher/ui/dialogs/AboutDialog.ui9
-rw-r--r--launcher/ui/pages/global/AccountListPage.cpp7
-rw-r--r--launcher/ui/pages/global/MinecraftPage.cpp2
-rw-r--r--launcher/ui/pages/global/MinecraftPage.ui10
-rw-r--r--launcher/ui/pages/instance/VersionPage.cpp3
-rw-r--r--launcher/ui/pages/modplatform/ModModel.cpp234
-rw-r--r--launcher/ui/pages/modplatform/ModModel.h85
-rw-r--r--launcher/ui/pages/modplatform/ModPage.cpp170
-rw-r--r--launcher/ui/pages/modplatform/ModPage.h69
-rw-r--r--launcher/ui/pages/modplatform/ModPage.ui (renamed from launcher/ui/pages/modplatform/modrinth/ModrinthPage.ui)4
-rw-r--r--launcher/ui/pages/modplatform/flame/FlameModModel.cpp268
-rw-r--r--launcher/ui/pages/modplatform/flame/FlameModModel.h78
-rw-r--r--launcher/ui/pages/modplatform/flame/FlameModPage.cpp240
-rw-r--r--launcher/ui/pages/modplatform/flame/FlameModPage.h72
-rw-r--r--launcher/ui/pages/modplatform/flame/FlameModPage.ui97
-rw-r--r--launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp289
-rw-r--r--launcher/ui/pages/modplatform/modrinth/ModrinthModel.h80
-rw-r--r--launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp227
-rw-r--r--launcher/ui/pages/modplatform/modrinth/ModrinthPage.h72
20 files changed, 790 insertions, 1304 deletions
diff --git a/launcher/ui/dialogs/AboutDialog.cpp b/launcher/ui/dialogs/AboutDialog.cpp
index ef96cc23..8dadb755 100644
--- a/launcher/ui/dialogs/AboutDialog.cpp
+++ b/launcher/ui/dialogs/AboutDialog.cpp
@@ -1,29 +1,63 @@
-/* Copyright 2013-2021 MultiMC Contributors
+// SPDX-License-Identifier: GPL-3.0-only
+/*
+ * PolyMC - Minecraft Launcher
+ * Copyright (C) 2022 Sefa Eyeoglu <contact@scrumplex.net>
*
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3.
*
- * http://www.apache.org/licenses/LICENSE-2.0
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
*
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
*/
#include "AboutDialog.h"
+#include "BuildConfig.h"
#include "ui_AboutDialog.h"
#include <QIcon>
#include "Application.h"
#include "BuildConfig.h"
#include <net/NetJob.h>
+#include <qobject.h>
#include "HoeDown.h"
namespace {
+QString getLink(QString link, QString name) {
+ return QString("&lt;<a href='%1'>%2</a>&gt;").arg(link).arg(name);
+}
+
+QString getWebsite(QString link) {
+ return getLink(link, QObject::tr("Website"));
+}
+
+QString getGitHub(QString username) {
+ return getLink("https://github.com/" + username, "GitHub");
+}
+
// Credits
// This is a hack, but I can't think of a better way to do this easily without screwing with QTextDocument...
QString getCreditsHtml()
@@ -33,15 +67,29 @@ QString getCreditsHtml()
stream.setCodec(QTextCodec::codecForName("UTF-8"));
stream << "<center>\n";
- stream << "<h3>" << QObject::tr("PolyMC Developers", "About Credits") << "</h3>\n";
- stream << "<p>swirl &lt;<a href='mailto:swurl@swurl.xyz'>swurl@swurl.xyz </a>&gt;</p>\n";
- stream << "<p>LennyMcLennington &lt;<a href='mailto:lenny@sneed.church'>lenny@sneed.church</a>&gt;</p>\n";
+ //: %1 is the name of the launcher, determined at build time, e.g. "PolyMC Developers"
+ stream << "<h3>" << QObject::tr("%1 Developers", "About Credits").arg(BuildConfig.LAUNCHER_NAME) << "</h3>\n";
+ stream << QString("<p>LennyMcLennington %1</p>\n") .arg(getGitHub("LennyMcLennington"));
+ stream << QString("<p>Sefa Eyeoglu (Scrumplex) %1</p>\n") .arg(getWebsite("https://scrumplex.net"));
+ stream << QString("<p>dada513 %1</p>\n") .arg(getGitHub("dada513"));
+ stream << QString("<p>txtsd %1</p>\n") .arg(getGitHub("txtsd"));
+ stream << QString("<p>timoreo %1</p>\n") .arg(getGitHub("timoreo22"));
+ stream << QString("<p>Ezekiel Smith (ZekeSmith) %1</p>\n") .arg(getGitHub("ZekeSmith"));
+ stream << QString("<p>cozyGalvinism %1</p>\n") .arg(getGitHub("cozyGalvinism"));
+ stream << "<br />\n";
+
+ //: %1 is the name of the launcher, determined at build time, e.g. "PolyMC Contributors"
+ stream << "<h3>" << QObject::tr("%1 Contributors", "About Credits").arg(BuildConfig.LAUNCHER_NAME) << "</h3>\n";
+ stream << QString("<p>DioEgizio %1</p>\n") .arg(getGitHub("DioEgizio"));
+ stream << QString("<p>flowln %1</p>\n") .arg(getGitHub("flowln"));
+ stream << QString("<p>swirl %1</p>\n") .arg(getWebsite("https://swurl.xyz/"));
stream << "<br />\n";
// TODO: possibly retrieve from git history at build time?
- stream << "<h3>" << QObject::tr("MultiMC Developers", "About Credits") << "</h3>\n";
+ //: %1 is the name of the launcher, determined at build time, e.g. "PolyMC Developers"
+ stream << "<h3>" << QObject::tr("%1 Developers", "About Credits").arg("MultiMC") << "</h3>\n";
stream << "<p>Andrew Okin &lt;<a href='mailto:forkk@forkk.net'>forkk@forkk.net</a>&gt;</p>\n";
- stream << "<p>Petr Mrázek &lt;<a href='mailto:peterix@gmail.com'>peterix@gmail.com</a>&gt;</p>\n";
+ stream << QString("<p>Petr Mrázek &lt;<a href='mailto:peterix@gmail.com'>peterix@gmail.com</a>&gt;</p>\n");
stream << "<p>Sky Welch &lt;<a href='mailto:multimc@bunnies.io'>multimc@bunnies.io</a>&gt;</p>\n";
stream << "<p>Jan (02JanDal) &lt;<a href='mailto:02jandal@gmail.com'>02jandal@gmail.com</a>&gt;</p>\n";
stream << "<p>RoboSky &lt;<a href='https://twitter.com/RoboSky_'>@RoboSky_</a>&gt;</p>\n";
diff --git a/launcher/ui/dialogs/AboutDialog.ui b/launcher/ui/dialogs/AboutDialog.ui
index 58275c66..f9665c30 100644
--- a/launcher/ui/dialogs/AboutDialog.ui
+++ b/launcher/ui/dialogs/AboutDialog.ui
@@ -87,7 +87,7 @@
</property>
</widget>
</item>
- <item>
+ <item>
<widget class="QLabel" name="versionLabel">
<property name="alignment">
<set>Qt::AlignCenter</set>
@@ -209,13 +209,10 @@
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
- <widget class="QTextEdit" name="creditsText">
- <property name="readOnly">
+ <widget class="QTextBrowser" name="creditsText">
+ <property name="openExternalLinks">
<bool>true</bool>
</property>
- <property name="textInteractionFlags">
- <set>Qt::TextBrowserInteraction</set>
- </property>
</widget>
</item>
</layout>
diff --git a/launcher/ui/pages/global/AccountListPage.cpp b/launcher/ui/pages/global/AccountListPage.cpp
index 1edba499..6e1e2183 100644
--- a/launcher/ui/pages/global/AccountListPage.cpp
+++ b/launcher/ui/pages/global/AccountListPage.cpp
@@ -161,10 +161,11 @@ void AccountListPage::on_actionAddMicrosoft_triggered()
CustomMessageBox::selectable(
this,
tr("Microsoft Accounts not available"),
+ //: %1 refers to the launcher itself
tr(
- "Microsoft accounts are only usable on macOS 10.13 or newer, with fully updated PolyMC.\n\n"
- "Please update both your operating system and PolyMC."
- ),
+ "Microsoft accounts are only usable on macOS 10.13 or newer, with fully updated %1.\n\n"
+ "Please update both your operating system and %1."
+ ).arg(BuildConfig.LAUNCHER_NAME),
QMessageBox::Warning
)->exec();
return;
diff --git a/launcher/ui/pages/global/MinecraftPage.cpp b/launcher/ui/pages/global/MinecraftPage.cpp
index 9abae425..f49f5a92 100644
--- a/launcher/ui/pages/global/MinecraftPage.cpp
+++ b/launcher/ui/pages/global/MinecraftPage.cpp
@@ -94,6 +94,7 @@ void MinecraftPage::applySettings()
// Miscellaneous
s->set("CloseAfterLaunch", ui->closeAfterLaunchCheck->isChecked());
+ s->set("QuitAfterGameStop", ui->quitAfterGameStopCheck->isChecked());
}
void MinecraftPage::loadSettings()
@@ -113,6 +114,7 @@ void MinecraftPage::loadSettings()
ui->recordGameTime->setChecked(s->get("RecordGameTime").toBool());
ui->closeAfterLaunchCheck->setChecked(s->get("CloseAfterLaunch").toBool());
+ ui->quitAfterGameStopCheck->setChecked(s->get("QuitAfterGameStop").toBool());
}
void MinecraftPage::retranslate()
diff --git a/launcher/ui/pages/global/MinecraftPage.ui b/launcher/ui/pages/global/MinecraftPage.ui
index a28b1f59..decc9b8b 100644
--- a/launcher/ui/pages/global/MinecraftPage.ui
+++ b/launcher/ui/pages/global/MinecraftPage.ui
@@ -180,6 +180,16 @@
</property>
</widget>
</item>
+ <item>
+ <widget class="QCheckBox" name="quitAfterGameStopCheck">
+ <property name="toolTip">
+ <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;PolyMC will automatically exit if the game crashes or exists.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ <property name="text">
+ <string>Quit PolyMC after game window stops</string>
+ </property>
+ </widget>
+ </item>
</layout>
</widget>
</item>
diff --git a/launcher/ui/pages/instance/VersionPage.cpp b/launcher/ui/pages/instance/VersionPage.cpp
index 97c6fe8f..ed37dd1a 100644
--- a/launcher/ui/pages/instance/VersionPage.cpp
+++ b/launcher/ui/pages/instance/VersionPage.cpp
@@ -2,6 +2,7 @@
/*
* PolyMC - Minecraft Launcher
* Copyright (c) 2022 Jamie Mansfield <jmansfield@cadixdev.org>
+ * Copyright (c) 2022 Sefa Eyeoglu <contact@scrumplex.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -422,7 +423,7 @@ void VersionPage::on_actionDownload_All_triggered()
{
CustomMessageBox::selectable(
this, tr("Error"),
- tr("PolyMC cannot download Minecraft or update instances unless you have at least "
+ tr("Cannot download Minecraft or update instances unless you have at least "
"one account added.\nPlease add your Mojang or Minecraft account."),
QMessageBox::Warning)->show();
return;
diff --git a/launcher/ui/pages/modplatform/ModModel.cpp b/launcher/ui/pages/modplatform/ModModel.cpp
new file mode 100644
index 00000000..01b5d247
--- /dev/null
+++ b/launcher/ui/pages/modplatform/ModModel.cpp
@@ -0,0 +1,234 @@
+#include "ModModel.h"
+
+#include "BuildConfig.h"
+#include "Json.h"
+#include "minecraft/MinecraftInstance.h"
+#include "minecraft/PackProfile.h"
+#include "ui/dialogs/ModDownloadDialog.h"
+
+#include <QMessageBox>
+
+namespace ModPlatform {
+
+ListModel::ListModel(ModPage* parent) : QAbstractListModel(parent), m_parent(parent) {}
+
+auto ListModel::debugName() const -> QString
+{
+ return m_parent->debugName();
+}
+
+/******** Make data requests ********/
+
+void ListModel::fetchMore(const QModelIndex& parent)
+{
+ if (parent.isValid()) return;
+ if (nextSearchOffset == 0) {
+ qWarning() << "fetchMore with 0 offset is wrong...";
+ return;
+ }
+ performPaginatedSearch();
+}
+
+auto ListModel::data(const QModelIndex& index, int role) const -> QVariant
+{
+ int pos = index.row();
+ if (pos >= modpacks.size() || pos < 0 || !index.isValid()) { return QString("INVALID INDEX %1").arg(pos); }
+
+ ModPlatform::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 {};
+}
+
+void ListModel::requestModVersions(ModPlatform::IndexedPack const& current)
+{
+ m_parent->apiProvider()->getVersions(this,
+ { current.addonId.toString(), getMineVersions(), hasFabric() ? ModAPI::ModLoaderType::Fabric : ModAPI::ModLoaderType::Forge });
+}
+
+void ListModel::performPaginatedSearch()
+{
+ m_parent->apiProvider()->searchMods(this,
+ { nextSearchOffset, currentSearchTerm, getSorts()[currentSort], hasFabric() ? ModAPI::Fabric : ModAPI::Forge, getMineVersions().at(0) });
+}
+
+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::getLogo(const QString& logo, const QString& logoUrl, LogoCallback callback)
+{
+ if (m_logoMap.contains(logo)) {
+ callback(APPLICATION->metacache()
+ ->resolveEntry(m_parent->metaEntryBase(), QString("logos/%1").arg(logo.section(".", 0, 0)))
+ ->getFullPath());
+ } else {
+ requestLogo(logo, logoUrl);
+ }
+}
+
+void ListModel::requestLogo(QString logo, QString url)
+{
+ if (m_loadingLogos.contains(logo) || m_failedLogos.contains(logo)) { return; }
+
+ MetaEntryPtr entry =
+ APPLICATION->metacache()->resolveEntry(m_parent->metaEntryBase(), QString("logos/%1").arg(logo.section(".", 0, 0)));
+ auto job = new NetJob(QString("%1 Icon Download %2").arg(m_parent->debugName()).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);
+}
+
+/******** Request callbacks ********/
+
+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::searchRequestFinished(QJsonDocument& doc)
+{
+ jobPtr.reset();
+
+ QList<ModPlatform::IndexedPack> newList;
+ auto packs = documentToArray(doc);
+
+ for (auto packRaw : packs) {
+ auto packObj = packRaw.toObject();
+
+ ModPlatform::IndexedPack pack;
+ try {
+ loadIndexedPack(pack, packObj);
+ newList.append(pack);
+ } catch (const JSONValidationError& e) {
+ qWarning() << "Error while loading mod from " << m_parent->debugName() << ": " << 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)
+{
+ if (jobPtr->first()->m_reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 409) {
+ // 409 Gone, notify user to update
+ QMessageBox::critical(nullptr, tr("Error"),
+ //: %1 refers to the launcher itself
+ QString("%1 %2").arg(m_parent->displayName()).arg(tr("API version too old!\nPlease update %1!").arg(BuildConfig.LAUNCHER_NAME)));
+ // self-destruct
+ (dynamic_cast<ModDownloadDialog*>((dynamic_cast<ModPage*>(parent()))->parentWidget()))->reject();
+ }
+ jobPtr.reset();
+
+ if (searchState == ResetRequested) {
+ beginResetModel();
+ modpacks.clear();
+ endResetModel();
+
+ nextSearchOffset = 0;
+ performPaginatedSearch();
+ } else {
+ searchState = Finished;
+ }
+}
+
+void ListModel::versionRequestSucceeded(QJsonDocument doc, QString addonId)
+{
+ auto& current = m_parent->getCurrent();
+ if (addonId != current.addonId) { return; }
+
+ QJsonArray arr = doc.array();
+ try {
+ loadIndexedPackVersions(current, arr);
+ } catch (const JSONValidationError& e) {
+ qDebug() << doc;
+ qWarning() << "Error while reading " << debugName() << " mod version: " << e.cause();
+ }
+
+ m_parent->updateModVersions();
+}
+
+} // namespace ModPlatform
+
+/******** Helpers ********/
+auto ModPlatform::ListModel::hasFabric() const -> bool
+{
+ return !(dynamic_cast<MinecraftInstance*>((dynamic_cast<ModPage*>(parent()))->m_instance))
+ ->getPackProfile()
+ ->getComponentVersion("net.fabricmc.fabric-loader")
+ .isEmpty();
+}
+
+auto ModPlatform::ListModel::getMineVersions() const -> QList<QString>
+{
+ return { (dynamic_cast<MinecraftInstance*>((dynamic_cast<ModPage*>(parent()))->m_instance))
+ ->getPackProfile()
+ ->getComponentVersion("net.minecraft") };
+}
diff --git a/launcher/ui/pages/modplatform/ModModel.h b/launcher/ui/pages/modplatform/ModModel.h
new file mode 100644
index 00000000..64cfa71e
--- /dev/null
+++ b/launcher/ui/pages/modplatform/ModModel.h
@@ -0,0 +1,85 @@
+#pragma once
+
+#include <QAbstractListModel>
+
+#include "modplatform/ModAPI.h"
+#include "modplatform/ModIndex.h"
+#include "net/NetJob.h"
+
+class ModPage;
+
+namespace ModPlatform {
+
+using LogoMap = QMap<QString, QIcon>;
+using LogoCallback = std::function<void (QString)>;
+
+class ListModel : public QAbstractListModel {
+ Q_OBJECT
+
+ public:
+ ListModel(ModPage* parent);
+ ~ListModel() override = default;
+
+ inline auto rowCount(const QModelIndex& parent) const -> int override { return modpacks.size(); };
+ inline auto columnCount(const QModelIndex& parent) const -> int override { return 1; };
+ inline auto flags(const QModelIndex& index) const -> Qt::ItemFlags override { return QAbstractListModel::flags(index); };
+
+ auto debugName() const -> QString;
+
+ /* Retrieve information from the model at a given index with the given role */
+ auto data(const QModelIndex& index, int role) const -> QVariant override;
+
+ inline void setActiveJob(NetJob::Ptr ptr) { jobPtr = ptr; }
+
+ /* Ask the API for more information */
+ void fetchMore(const QModelIndex& parent) override;
+ void searchWithTerm(const QString& term, const int sort);
+ void requestModVersions(const ModPlatform::IndexedPack& current);
+
+ virtual void loadIndexedPack(ModPlatform::IndexedPack& m, QJsonObject& obj) = 0;
+ virtual void loadIndexedPackVersions(ModPlatform::IndexedPack& m, QJsonArray& arr) = 0;
+
+ void getLogo(const QString& logo, const QString& logoUrl, LogoCallback callback);
+
+ inline auto canFetchMore(const QModelIndex& parent) const -> bool override { return searchState == CanPossiblyFetchMore; };
+
+ public slots:
+ void searchRequestFinished(QJsonDocument& doc);
+ void searchRequestFailed(QString reason);
+
+ void versionRequestSucceeded(QJsonDocument doc, QString addonId);
+
+ protected slots:
+
+ void logoFailed(QString logo);
+ void logoLoaded(QString logo, QIcon out);
+
+ void performPaginatedSearch();
+
+ protected:
+ virtual auto documentToArray(QJsonDocument& obj) const -> QJsonArray = 0;
+ virtual auto getSorts() const -> const char** = 0;
+
+ void requestLogo(QString file, QString url);
+
+ inline auto hasFabric() const -> bool;
+ inline auto getMineVersions() const -> QList<QString>;
+
+ protected:
+ ModPage* m_parent;
+
+ QList<ModPlatform::IndexedPack> modpacks;
+
+ LogoMap m_logoMap;
+ QMap<QString, LogoCallback> waitingCallbacks;
+ QStringList m_failedLogos;
+ QStringList m_loadingLogos;
+
+ QString currentSearchTerm;
+ int currentSort = 0;
+ int nextSearchOffset = 0;
+ enum SearchState { None, CanPossiblyFetchMore, ResetRequested, Finished } searchState = None;
+
+ NetJob::Ptr jobPtr;
+};
+} // namespace ModPlatform
diff --git a/launcher/ui/pages/modplatform/ModPage.cpp b/launcher/ui/pages/modplatform/ModPage.cpp
new file mode 100644
index 00000000..3a116d3c
--- /dev/null
+++ b/launcher/ui/pages/modplatform/ModPage.cpp
@@ -0,0 +1,170 @@
+#include "ModPage.h"
+#include "ui_ModPage.h"
+
+#include <QKeyEvent>
+
+#include "minecraft/MinecraftInstance.h"
+#include "minecraft/PackProfile.h"
+#include "ui/dialogs/ModDownloadDialog.h"
+
+ModPage::ModPage(ModDownloadDialog* dialog, BaseInstance* instance, ModAPI* api)
+ : QWidget(dialog), m_instance(instance), ui(new Ui::ModPage), dialog(dialog), api(api)
+{
+ ui->setupUi(this);
+ connect(ui->searchButton, &QPushButton::clicked, this, &ModPage::triggerSearch);
+ ui->searchEdit->installEventFilter(this);
+
+ ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
+ ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300);
+
+}
+
+ModPage::~ModPage()
+{
+ delete ui;
+}
+
+
+/******** Qt things ********/
+
+void ModPage::openedImpl()
+{
+ updateSelectionButton();
+ triggerSearch();
+}
+
+auto ModPage::eventFilter(QObject* watched, QEvent* event) -> bool
+{
+ if (watched == ui->searchEdit && event->type() == QEvent::KeyPress) {
+ auto* keyEvent = dynamic_cast<QKeyEvent*>(event);
+ if (keyEvent->key() == Qt::Key_Return) {
+ triggerSearch();
+ keyEvent->accept();
+ return true;
+ }
+ }
+ return QWidget::eventFilter(watched, event);
+}
+
+
+/******** Callbacks to events in the UI (set up in the derived classes) ********/
+
+void ModPage::triggerSearch()
+{
+ listModel->searchWithTerm(ui->searchEdit->text(), ui->sortByBox->currentIndex());
+}
+
+void ModPage::onSelectionChanged(QModelIndex first, QModelIndex second)
+{
+ ui->versionSelectionBox->clear();
+
+ if (!first.isValid()) { return; }
+
+ current = listModel->data(first, Qt::UserRole).value<ModPlatform::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 = [](ModPlatform::ModpackAuthor& author) -> QString {
+ 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() << QString("Loading %1 mod versions").arg(debugName());
+
+ ui->modSelectionButton->setText(tr("Loading versions..."));
+ ui->modSelectionButton->setEnabled(false);
+
+ listModel->requestModVersions(current);
+ } else {
+ for (int i = 0; i < current.versions.size(); i++) {
+ ui->versionSelectionBox->addItem(current.versions[i].version, QVariant(i));
+ }
+ if (ui->versionSelectionBox->count() == 0) { ui->versionSelectionBox->addItem(tr("No valid version found."), QVariant(-1)); }
+
+ updateSelectionButton();
+ }
+}
+
+void ModPage::onVersionSelectionChanged(QString data)
+{
+ if (data.isNull() || data.isEmpty()) {
+ selectedVersion = -1;
+ return;
+ }
+ selectedVersion = ui->versionSelectionBox->currentData().toInt();
+ updateSelectionButton();
+}
+
+void ModPage::onModSelected()
+{
+ auto& version = current.versions[selectedVersion];
+ if (dialog->isModSelected(current.name, version.fileName)) {
+ dialog->removeSelectedMod(current.name);
+ } else {
+ dialog->addSelectedMod(current.name, new ModDownloadTask(version.downloadUrl, version.fileName, dialog->mods));
+ }
+
+ updateSelectionButton();
+}
+
+
+/******** Make changes to the UI ********/
+
+void ModPage::retranslate()
+{
+ ui->retranslateUi(this);
+}
+
+void ModPage::updateModVersions()
+{
+ auto packProfile = (dynamic_cast<MinecraftInstance*>(m_instance))->getPackProfile();
+
+ QString mcVersion = packProfile->getComponentVersion("net.minecraft");
+ QString loaderString = (packProfile->getComponentVersion("net.minecraftforge").isEmpty()) ? "fabric" : "forge";
+
+ for (int i = 0; i < current.versions.size(); i++) {
+ auto version = current.versions[i];
+ //NOTE: Flame doesn't care about loaderString, so passing it changes nothing.
+ if (!validateVersion(version, mcVersion, loaderString)) {
+ continue;
+ }
+ ui->versionSelectionBox->addItem(version.version, QVariant(i));
+ }
+ if (ui->versionSelectionBox->count() == 0) { ui->versionSelectionBox->addItem(tr("No valid version found!"), QVariant(-1)); }
+
+ ui->modSelectionButton->setText(tr("Cannot select invalid version :("));
+ updateSelectionButton();
+}
+
+
+void ModPage::updateSelectionButton()
+{
+ if (!isOpened || selectedVersion < 0) {
+ ui->modSelectionButton->setEnabled(false);
+ return;
+ }
+
+ ui->modSelectionButton->setEnabled(true);
+ auto& version = current.versions[selectedVersion];
+ if (!dialog->isModSelected(current.name, version.fileName)) {
+ ui->modSelectionButton->setText(tr("Select mod for download"));
+ } else {
+ ui->modSelectionButton->setText(tr("Deselect mod for download"));
+ }
+}
diff --git a/launcher/ui/pages/modplatform/ModPage.h b/launcher/ui/pages/modplatform/ModPage.h
new file mode 100644
index 00000000..0cd13f37
--- /dev/null
+++ b/launcher/ui/pages/modplatform/ModPage.h
@@ -0,0 +1,69 @@
+#pragma once
+
+#include <QWidget>
+
+#include "Application.h"
+#include "modplatform/ModAPI.h"
+#include "modplatform/ModIndex.h"
+#include "ui/pages/BasePage.h"
+#include "ui/pages/modplatform/ModModel.h"
+
+class ModDownloadDialog;
+
+namespace Ui {
+class ModPage;
+}
+
+/* This page handles most logic related to browsing and selecting mods to download. */
+class ModPage : public QWidget, public BasePage {
+ Q_OBJECT
+
+ public:
+ explicit ModPage(ModDownloadDialog* dialog, BaseInstance* instance, ModAPI* api);
+ ~ModPage() override;
+
+ /* Affects what the user sees */
+ auto displayName() const -> QString override = 0;
+ auto icon() const -> QIcon override = 0;
+ auto id() const -> QString override = 0;
+ auto helpPage() const -> QString override = 0;
+
+ /* Used internally */
+ virtual auto metaEntryBase() const -> QString = 0;
+ virtual auto debugName() const -> QString = 0;
+
+
+ void retranslate() override;
+
+ auto shouldDisplay() const -> bool override = 0;
+ virtual auto validateVersion(ModPlatform::IndexedVersion& ver, QString mineVer, QString loaderVer = "") const -> bool = 0;
+
+ auto apiProvider() const -> const ModAPI* { return api.get(); };
+
+ auto getCurrent() -> ModPlatform::IndexedPack& { return current; }
+ void updateModVersions();
+
+ void openedImpl() override;
+ auto eventFilter(QObject* watched, QEvent* event) -> bool override;
+
+ BaseInstance* m_instance;
+
+ protected:
+ void updateSelectionButton();
+
+ protected slots:
+ void triggerSearch();
+ void onSelectionChanged(QModelIndex first, QModelIndex second);
+ void onVersionSelecti