summaryrefslogtreecommitdiff
path: root/meshmc/launcher/ui
diff options
context:
space:
mode:
Diffstat (limited to 'meshmc/launcher/ui')
-rw-r--r--meshmc/launcher/ui/ColorCache.cpp53
-rw-r--r--meshmc/launcher/ui/ColorCache.h132
-rw-r--r--meshmc/launcher/ui/GuiUtil.cpp154
-rw-r--r--meshmc/launcher/ui/GuiUtil.h34
-rw-r--r--meshmc/launcher/ui/InstanceWindow.cpp258
-rw-r--r--meshmc/launcher/ui/InstanceWindow.h99
-rw-r--r--meshmc/launcher/ui/MainWindow.cpp2024
-rw-r--r--meshmc/launcher/ui/MainWindow.h249
-rw-r--r--meshmc/launcher/ui/dialogs/AboutDialog.cpp146
-rw-r--r--meshmc/launcher/ui/dialogs/AboutDialog.h62
-rw-r--r--meshmc/launcher/ui/dialogs/AboutDialog.ui335
-rw-r--r--meshmc/launcher/ui/dialogs/BlockedModsDialog.cpp178
-rw-r--r--meshmc/launcher/ui/dialogs/BlockedModsDialog.h74
-rw-r--r--meshmc/launcher/ui/dialogs/CopyInstanceDialog.cpp157
-rw-r--r--meshmc/launcher/ui/dialogs/CopyInstanceDialog.h80
-rw-r--r--meshmc/launcher/ui/dialogs/CopyInstanceDialog.ui182
-rw-r--r--meshmc/launcher/ui/dialogs/CustomMessageBox.cpp59
-rw-r--r--meshmc/launcher/ui/dialogs/CustomMessageBox.h50
-rw-r--r--meshmc/launcher/ui/dialogs/EditAccountDialog.cpp85
-rw-r--r--meshmc/launcher/ui/dialogs/EditAccountDialog.h78
-rw-r--r--meshmc/launcher/ui/dialogs/EditAccountDialog.ui94
-rw-r--r--meshmc/launcher/ui/dialogs/ExportInstanceDialog.cpp462
-rw-r--r--meshmc/launcher/ui/dialogs/ExportInstanceDialog.h77
-rw-r--r--meshmc/launcher/ui/dialogs/ExportInstanceDialog.ui83
-rw-r--r--meshmc/launcher/ui/dialogs/IconPickerDialog.cpp197
-rw-r--r--meshmc/launcher/ui/dialogs/IconPickerDialog.h71
-rw-r--r--meshmc/launcher/ui/dialogs/IconPickerDialog.ui67
-rw-r--r--meshmc/launcher/ui/dialogs/JavaDownloadDialog.cpp399
-rw-r--r--meshmc/launcher/ui/dialogs/JavaDownloadDialog.h82
-rw-r--r--meshmc/launcher/ui/dialogs/MSALoginDialog.cpp147
-rw-r--r--meshmc/launcher/ui/dialogs/MSALoginDialog.h77
-rw-r--r--meshmc/launcher/ui/dialogs/MSALoginDialog.ui65
-rw-r--r--meshmc/launcher/ui/dialogs/NewComponentDialog.cpp129
-rw-r--r--meshmc/launcher/ui/dialogs/NewComponentDialog.h73
-rw-r--r--meshmc/launcher/ui/dialogs/NewComponentDialog.ui101
-rw-r--r--meshmc/launcher/ui/dialogs/NewInstanceDialog.cpp278
-rw-r--r--meshmc/launcher/ui/dialogs/NewInstanceDialog.h106
-rw-r--r--meshmc/launcher/ui/dialogs/NewInstanceDialog.ui87
-rw-r--r--meshmc/launcher/ui/dialogs/NotificationDialog.cpp103
-rw-r--r--meshmc/launcher/ui/dialogs/NotificationDialog.h63
-rw-r--r--meshmc/launcher/ui/dialogs/NotificationDialog.ui85
-rw-r--r--meshmc/launcher/ui/dialogs/ProfileSelectDialog.cpp137
-rw-r--r--meshmc/launcher/ui/dialogs/ProfileSelectDialog.h115
-rw-r--r--meshmc/launcher/ui/dialogs/ProfileSelectDialog.ui62
-rw-r--r--meshmc/launcher/ui/dialogs/ProfileSetupDialog.cpp298
-rw-r--r--meshmc/launcher/ui/dialogs/ProfileSetupDialog.h105
-rw-r--r--meshmc/launcher/ui/dialogs/ProfileSetupDialog.ui74
-rw-r--r--meshmc/launcher/ui/dialogs/ProgressDialog.cpp201
-rw-r--r--meshmc/launcher/ui/dialogs/ProgressDialog.h91
-rw-r--r--meshmc/launcher/ui/dialogs/ProgressDialog.ui66
-rw-r--r--meshmc/launcher/ui/dialogs/SkinUploadDialog.cpp163
-rw-r--r--meshmc/launcher/ui/dialogs/SkinUploadDialog.h51
-rw-r--r--meshmc/launcher/ui/dialogs/SkinUploadDialog.ui97
-rw-r--r--meshmc/launcher/ui/dialogs/UpdateDialog.cpp77
-rw-r--r--meshmc/launcher/ui/dialogs/UpdateDialog.h80
-rw-r--r--meshmc/launcher/ui/dialogs/UpdateDialog.ui91
-rw-r--r--meshmc/launcher/ui/dialogs/VersionSelectDialog.cpp166
-rw-r--r--meshmc/launcher/ui/dialogs/VersionSelectDialog.h101
-rw-r--r--meshmc/launcher/ui/instanceview/AccessibleInstanceView.cpp859
-rw-r--r--meshmc/launcher/ui/instanceview/AccessibleInstanceView.h28
-rw-r--r--meshmc/launcher/ui/instanceview/AccessibleInstanceView_p.h155
-rw-r--r--meshmc/launcher/ui/instanceview/InstanceDelegate.cpp462
-rw-r--r--meshmc/launcher/ui/instanceview/InstanceDelegate.h69
-rw-r--r--meshmc/launcher/ui/instanceview/InstanceProxyModel.cpp100
-rw-r--r--meshmc/launcher/ui/instanceview/InstanceProxyModel.h60
-rw-r--r--meshmc/launcher/ui/instanceview/InstanceView.cpp952
-rw-r--r--meshmc/launcher/ui/instanceview/InstanceView.h192
-rw-r--r--meshmc/launcher/ui/instanceview/VisualGroup.cpp334
-rw-r--r--meshmc/launcher/ui/instanceview/VisualGroup.h126
-rw-r--r--meshmc/launcher/ui/pagedialog/PageDialog.cpp90
-rw-r--r--meshmc/launcher/ui/pagedialog/PageDialog.h58
-rw-r--r--meshmc/launcher/ui/pages/BasePage.h92
-rw-r--r--meshmc/launcher/ui/pages/BasePageContainer.h31
-rw-r--r--meshmc/launcher/ui/pages/BasePageProvider.h93
-rw-r--r--meshmc/launcher/ui/pages/global/AccountListPage.cpp261
-rw-r--r--meshmc/launcher/ui/pages/global/AccountListPage.h106
-rw-r--r--meshmc/launcher/ui/pages/global/AccountListPage.ui123
-rw-r--r--meshmc/launcher/ui/pages/global/AppearancePage.cpp215
-rw-r--r--meshmc/launcher/ui/pages/global/AppearancePage.h71
-rw-r--r--meshmc/launcher/ui/pages/global/AppearancePage.ui307
-rw-r--r--meshmc/launcher/ui/pages/global/CustomCommandsPage.cpp66
-rw-r--r--meshmc/launcher/ui/pages/global/CustomCommandsPage.h78
-rw-r--r--meshmc/launcher/ui/pages/global/ExternalToolsPage.cpp251
-rw-r--r--meshmc/launcher/ui/pages/global/ExternalToolsPage.h96
-rw-r--r--meshmc/launcher/ui/pages/global/ExternalToolsPage.ui194
-rw-r--r--meshmc/launcher/ui/pages/global/JavaPage.cpp287
-rw-r--r--meshmc/launcher/ui/pages/global/JavaPage.h99
-rw-r--r--meshmc/launcher/ui/pages/global/JavaPage.ui346
-rw-r--r--meshmc/launcher/ui/pages/global/LanguagePage.cpp68
-rw-r--r--meshmc/launcher/ui/pages/global/LanguagePage.h83
-rw-r--r--meshmc/launcher/ui/pages/global/MeshMCPage.cpp318
-rw-r--r--meshmc/launcher/ui/pages/global/MeshMCPage.h120
-rw-r--r--meshmc/launcher/ui/pages/global/MeshMCPage.ui482
-rw-r--r--meshmc/launcher/ui/pages/global/MinecraftPage.cpp115
-rw-r--r--meshmc/launcher/ui/pages/global/MinecraftPage.h91
-rw-r--r--meshmc/launcher/ui/pages/global/MinecraftPage.ui196
-rw-r--r--meshmc/launcher/ui/pages/global/PasteEEPage.cpp100
-rw-r--r--meshmc/launcher/ui/pages/global/PasteEEPage.h86
-rw-r--r--meshmc/launcher/ui/pages/global/PasteEEPage.ui128
-rw-r--r--meshmc/launcher/ui/pages/global/ProxyPage.cpp126
-rw-r--r--meshmc/launcher/ui/pages/global/ProxyPage.h88
-rw-r--r--meshmc/launcher/ui/pages/global/ProxyPage.ui203
-rw-r--r--meshmc/launcher/ui/pages/instance/GameOptionsPage.cpp56
-rw-r--r--meshmc/launcher/ui/pages/instance/GameOptionsPage.h86
-rw-r--r--meshmc/launcher/ui/pages/instance/GameOptionsPage.ui88
-rw-r--r--meshmc/launcher/ui/pages/instance/InstanceSettingsPage.cpp353
-rw-r--r--meshmc/launcher/ui/pages/instance/InstanceSettingsPage.h99
-rw-r--r--meshmc/launcher/ui/pages/instance/InstanceSettingsPage.ui548
-rw-r--r--meshmc/launcher/ui/pages/instance/LegacyUpgradePage.cpp74
-rw-r--r--meshmc/launcher/ui/pages/instance/LegacyUpgradePage.h87
-rw-r--r--meshmc/launcher/ui/pages/instance/LegacyUpgradePage.ui47
-rw-r--r--meshmc/launcher/ui/pages/instance/LogPage.cpp337
-rw-r--r--meshmc/launcher/ui/pages/instance/LogPage.h110
-rw-r--r--meshmc/launcher/ui/pages/instance/LogPage.ui182
-rw-r--r--meshmc/launcher/ui/pages/instance/ModFolderPage.cpp407
-rw-r--r--meshmc/launcher/ui/pages/instance/ModFolderPage.h136
-rw-r--r--meshmc/launcher/ui/pages/instance/ModFolderPage.ui164
-rw-r--r--meshmc/launcher/ui/pages/instance/NotesPage.cpp42
-rw-r--r--meshmc/launcher/ui/pages/instance/NotesPage.h83
-rw-r--r--meshmc/launcher/ui/pages/instance/NotesPage.ui49
-rw-r--r--meshmc/launcher/ui/pages/instance/OtherLogsPage.cpp314
-rw-r--r--meshmc/launcher/ui/pages/instance/OtherLogsPage.h105
-rw-r--r--meshmc/launcher/ui/pages/instance/OtherLogsPage.ui150
-rw-r--r--meshmc/launcher/ui/pages/instance/ResourcePackPage.h45
-rw-r--r--meshmc/launcher/ui/pages/instance/ScreenshotsPage.cpp458
-rw-r--r--meshmc/launcher/ui/pages/instance/ScreenshotsPage.h109
-rw-r--r--meshmc/launcher/ui/pages/instance/ScreenshotsPage.ui87
-rw-r--r--meshmc/launcher/ui/pages/instance/ServersPage.cpp734
-rw-r--r--meshmc/launcher/ui/pages/instance/ServersPage.h117
-rw-r--r--meshmc/launcher/ui/pages/instance/ServersPage.ui194
-rw-r--r--meshmc/launcher/ui/pages/instance/ShaderPackPage.h44
-rw-r--r--meshmc/launcher/ui/pages/instance/TexturePackPage.h44
-rw-r--r--meshmc/launcher/ui/pages/instance/VersionPage.cpp809
-rw-r--r--meshmc/launcher/ui/pages/instance/VersionPage.h131
-rw-r--r--meshmc/launcher/ui/pages/instance/VersionPage.ui303
-rw-r--r--meshmc/launcher/ui/pages/instance/WorldListPage.cpp422
-rw-r--r--meshmc/launcher/ui/pages/instance/WorldListPage.h120
-rw-r--r--meshmc/launcher/ui/pages/instance/WorldListPage.ui161
-rw-r--r--meshmc/launcher/ui/pages/modplatform/ImportPage.cpp136
-rw-r--r--meshmc/launcher/ui/pages/modplatform/ImportPage.h92
-rw-r--r--meshmc/launcher/ui/pages/modplatform/ImportPage.ui52
-rw-r--r--meshmc/launcher/ui/pages/modplatform/VanillaPage.cpp128
-rw-r--r--meshmc/launcher/ui/pages/modplatform/VanillaPage.h98
-rw-r--r--meshmc/launcher/ui/pages/modplatform/VanillaPage.ui169
-rw-r--r--meshmc/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.cpp130
-rw-r--r--meshmc/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.h74
-rw-r--r--meshmc/launcher/ui/pages/modplatform/atlauncher/AtlListModel.cpp234
-rw-r--r--meshmc/launcher/ui/pages/modplatform/atlauncher/AtlListModel.h92
-rw-r--r--meshmc/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.cpp266
-rw-r--r--meshmc/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.h111
-rw-r--r--meshmc/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.ui65
-rw-r--r--meshmc/launcher/ui/pages/modplatform/atlauncher/AtlPage.cpp227
-rw-r--r--meshmc/launcher/ui/pages/modplatform/atlauncher/AtlPage.h113
-rw-r--r--meshmc/launcher/ui/pages/modplatform/atlauncher/AtlPage.ui92
-rw-r--r--meshmc/launcher/ui/pages/modplatform/flame/FlameModel.cpp298
-rw-r--r--meshmc/launcher/ui/pages/modplatform/flame/FlameModel.h98
-rw-r--r--meshmc/launcher/ui/pages/modplatform/flame/FlamePage.cpp246
-rw-r--r--meshmc/launcher/ui/pages/modplatform/flame/FlamePage.h105
-rw-r--r--meshmc/launcher/ui/pages/modplatform/flame/FlamePage.ui90
-rw-r--r--meshmc/launcher/ui/pages/modplatform/ftb/FtbFilterModel.cpp123
-rw-r--r--meshmc/launcher/ui/pages/modplatform/ftb/FtbFilterModel.h75
-rw-r--r--meshmc/launcher/ui/pages/modplatform/ftb/FtbListModel.cpp318
-rw-r--r--meshmc/launcher/ui/pages/modplatform/ftb/FtbListModel.h101
-rw-r--r--meshmc/launcher/ui/pages/modplatform/ftb/FtbPage.cpp193
-rw-r--r--meshmc/launcher/ui/pages/modplatform/ftb/FtbPage.h106
-rw-r--r--meshmc/launcher/ui/pages/modplatform/ftb/FtbPage.ui79
-rw-r--r--meshmc/launcher/ui/pages/modplatform/legacy_ftb/ListModel.cpp270
-rw-r--r--meshmc/launcher/ui/pages/modplatform/legacy_ftb/ListModel.h97
-rw-r--r--meshmc/launcher/ui/pages/modplatform/legacy_ftb/Page.cpp387
-rw-r--r--meshmc/launcher/ui/pages/modplatform/legacy_ftb/Page.h147
-rw-r--r--meshmc/launcher/ui/pages/modplatform/legacy_ftb/Page.ui135
-rw-r--r--meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp273
-rw-r--r--meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h95
-rw-r--r--meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp232
-rw-r--r--meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h87
-rw-r--r--meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthPage.ui90
-rw-r--r--meshmc/launcher/ui/pages/modplatform/technic/TechnicData.h66
-rw-r--r--meshmc/launcher/ui/pages/modplatform/technic/TechnicModel.cpp245
-rw-r--r--meshmc/launcher/ui/pages/modplatform/technic/TechnicModel.h91
-rw-r--r--meshmc/launcher/ui/pages/modplatform/technic/TechnicPage.cpp228
-rw-r--r--meshmc/launcher/ui/pages/modplatform/technic/TechnicPage.h102
-rw-r--r--meshmc/launcher/ui/pages/modplatform/technic/TechnicPage.ui95
-rw-r--r--meshmc/launcher/ui/setupwizard/AnalyticsWizardPage.cpp88
-rw-r--r--meshmc/launcher/ui/setupwizard/AnalyticsWizardPage.h46
-rw-r--r--meshmc/launcher/ui/setupwizard/BaseWizardPage.h50
-rw-r--r--meshmc/launcher/ui/setupwizard/JavaWizardPage.cpp111
-rw-r--r--meshmc/launcher/ui/setupwizard/JavaWizardPage.h47
-rw-r--r--meshmc/launcher/ui/setupwizard/LanguageWizardPage.cpp68
-rw-r--r--meshmc/launcher/ui/setupwizard/LanguageWizardPage.h47
-rw-r--r--meshmc/launcher/ui/setupwizard/SetupWizard.cpp105
-rw-r--r--meshmc/launcher/ui/setupwizard/SetupWizard.h67
-rw-r--r--meshmc/launcher/ui/themes/BrightTheme.cpp83
-rw-r--r--meshmc/launcher/ui/themes/BrightTheme.h40
-rw-r--r--meshmc/launcher/ui/themes/CatPack.cpp166
-rw-r--r--meshmc/launcher/ui/themes/CatPack.h87
-rw-r--r--meshmc/launcher/ui/themes/CustomTheme.cpp248
-rw-r--r--meshmc/launcher/ui/themes/CustomTheme.h51
-rw-r--r--meshmc/launcher/ui/themes/DarkTheme.cpp85
-rw-r--r--meshmc/launcher/ui/themes/DarkTheme.h40
-rw-r--r--meshmc/launcher/ui/themes/FusionTheme.cpp27
-rw-r--r--meshmc/launcher/ui/themes/FusionTheme.h32
-rw-r--r--meshmc/launcher/ui/themes/ITheme.cpp58
-rw-r--r--meshmc/launcher/ui/themes/ITheme.h60
-rw-r--r--meshmc/launcher/ui/themes/SystemTheme.cpp134
-rw-r--r--meshmc/launcher/ui/themes/SystemTheme.h49
-rw-r--r--meshmc/launcher/ui/themes/ThemeManager.cpp369
-rw-r--r--meshmc/launcher/ui/themes/ThemeManager.h91
-rw-r--r--meshmc/launcher/ui/widgets/Common.cpp47
-rw-r--r--meshmc/launcher/ui/widgets/Common.h27
-rw-r--r--meshmc/launcher/ui/widgets/CustomCommands.cpp69
-rw-r--r--meshmc/launcher/ui/widgets/CustomCommands.h65
-rw-r--r--meshmc/launcher/ui/widgets/CustomCommands.ui107
-rw-r--r--meshmc/launcher/ui/widgets/DropLabel.cpp61
-rw-r--r--meshmc/launcher/ui/widgets/DropLabel.h41
-rw-r--r--meshmc/launcher/ui/widgets/ErrorFrame.cpp142
-rw-r--r--meshmc/launcher/ui/widgets/ErrorFrame.h72
-rw-r--r--meshmc/launcher/ui/widgets/ErrorFrame.ui92
-rw-r--r--meshmc/launcher/ui/widgets/FocusLineEdit.cpp45
-rw-r--r--meshmc/launcher/ui/widgets/FocusLineEdit.h36
-rw-r--r--meshmc/launcher/ui/widgets/IconLabel.cpp61
-rw-r--r--meshmc/launcher/ui/widgets/IconLabel.h47
-rw-r--r--meshmc/launcher/ui/widgets/InstanceCardWidget.ui58
-rw-r--r--meshmc/launcher/ui/widgets/JavaSettingsWidget.cpp438
-rw-r--r--meshmc/launcher/ui/widgets/JavaSettingsWidget.h116
-rw-r--r--meshmc/launcher/ui/widgets/LabeledToolButton.cpp136
-rw-r--r--meshmc/launcher/ui/widgets/LabeledToolButton.h64
-rw-r--r--meshmc/launcher/ui/widgets/LanguageSelectionWidget.cpp91
-rw-r--r--meshmc/launcher/ui/widgets/LanguageSelectionWidget.h65
-rw-r--r--meshmc/launcher/ui/widgets/LineSeparator.cpp59
-rw-r--r--meshmc/launcher/ui/widgets/LineSeparator.h41
-rw-r--r--meshmc/launcher/ui/widgets/LogView.cpp161
-rw-r--r--meshmc/launcher/ui/widgets/LogView.h57
-rw-r--r--meshmc/launcher/ui/widgets/MCModInfoFrame.cpp173
-rw-r--r--meshmc/launcher/ui/widgets/MCModInfoFrame.h74
-rw-r--r--meshmc/launcher/ui/widgets/MCModInfoFrame.ui92
-rw-r--r--meshmc/launcher/ui/widgets/ModListView.cpp84
-rw-r--r--meshmc/launcher/ui/widgets/ModListView.h48
-rw-r--r--meshmc/launcher/ui/widgets/PageContainer.cpp253
-rw-r--r--meshmc/launcher/ui/widgets/PageContainer.h112
-rw-r--r--meshmc/launcher/ui/widgets/PageContainer_p.h143
-rw-r--r--meshmc/launcher/ui/widgets/ProgressWidget.cpp95
-rw-r--r--meshmc/launcher/ui/widgets/ProgressWidget.h55
-rw-r--r--meshmc/launcher/ui/widgets/VersionListView.cpp179
-rw-r--r--meshmc/launcher/ui/widgets/VersionListView.h75
-rw-r--r--meshmc/launcher/ui/widgets/VersionSelectWidget.cpp232
-rw-r--r--meshmc/launcher/ui/widgets/VersionSelectWidget.h104
-rw-r--r--meshmc/launcher/ui/widgets/WideBar.cpp135
-rw-r--r--meshmc/launcher/ui/widgets/WideBar.h48
248 files changed, 37910 insertions, 0 deletions
diff --git a/meshmc/launcher/ui/ColorCache.cpp b/meshmc/launcher/ui/ColorCache.cpp
new file mode 100644
index 0000000000..0e9ab397ca
--- /dev/null
+++ b/meshmc/launcher/ui/ColorCache.cpp
@@ -0,0 +1,53 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ColorCache.h"
+
+/**
+ * Blend the color with the front color, adapting to the back color
+ */
+QColor ColorCache::blend(QColor color)
+{
+ if (Rainbow::luma(m_front) > Rainbow::luma(m_back)) {
+ // for dark color schemes, produce a fitting color first
+ color = Rainbow::tint(m_front, color, 0.5);
+ }
+ // adapt contrast
+ return Rainbow::mix(m_front, color, m_bias);
+}
+
+/**
+ * Blend the color with the back color
+ */
+QColor ColorCache::blendBackground(QColor color)
+{
+ // adapt contrast
+ return Rainbow::mix(m_back, color, m_bias);
+}
+
+void ColorCache::recolorAll()
+{
+ auto iter = m_colors.begin();
+ while (iter != m_colors.end()) {
+ iter->front = blend(iter->original);
+ iter->back = blendBackground(iter->original);
+ }
+}
diff --git a/meshmc/launcher/ui/ColorCache.h b/meshmc/launcher/ui/ColorCache.h
new file mode 100644
index 0000000000..d2b55e4d61
--- /dev/null
+++ b/meshmc/launcher/ui/ColorCache.h
@@ -0,0 +1,132 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QtGui/QColor>
+#include <rainbow.h>
+#include <MessageLevel.h>
+#include <QMap>
+
+class ColorCache
+{
+ public:
+ ColorCache(QColor front, QColor back, qreal bias)
+ {
+ m_front = front;
+ m_back = back;
+ m_bias = bias;
+ };
+
+ void addColor(int key, QColor color)
+ {
+ m_colors[key] = {color, blend(color), blendBackground(color)};
+ }
+
+ void setForeground(QColor front)
+ {
+ if (m_front != front) {
+ m_front = front;
+ recolorAll();
+ }
+ }
+
+ void setBackground(QColor back)
+ {
+ if (m_back != back) {
+ m_back = back;
+ recolorAll();
+ }
+ }
+
+ QColor getFront(int key)
+ {
+ auto iter = m_colors.find(key);
+ if (iter == m_colors.end()) {
+ return QColor();
+ }
+ return (*iter).front;
+ }
+
+ QColor getBack(int key)
+ {
+ auto iter = m_colors.find(key);
+ if (iter == m_colors.end()) {
+ return QColor();
+ }
+ return (*iter).back;
+ }
+
+ /**
+ * Blend the color with the front color, adapting to the back color
+ */
+ QColor blend(QColor color);
+
+ /**
+ * Blend the color with the back color
+ */
+ QColor blendBackground(QColor color);
+
+ protected:
+ void recolorAll();
+
+ protected:
+ struct ColorEntry {
+ QColor original;
+ QColor front;
+ QColor back;
+ };
+
+ protected:
+ qreal m_bias;
+ QColor m_front;
+ QColor m_back;
+ QMap<int, ColorEntry> m_colors;
+};
+
+class LogColorCache : public ColorCache
+{
+ public:
+ LogColorCache(QColor front, QColor back) : ColorCache(front, back, 1.0)
+ {
+ addColor((int)MessageLevel::MeshMC, QColor("purple"));
+ addColor((int)MessageLevel::Debug, QColor("green"));
+ addColor((int)MessageLevel::Warning, QColor("orange"));
+ addColor((int)MessageLevel::Error, QColor("red"));
+ addColor((int)MessageLevel::Fatal, QColor("red"));
+ addColor((int)MessageLevel::Message, front);
+ }
+
+ QColor getFront(MessageLevel::Enum level)
+ {
+ if (!m_colors.contains((int)level)) {
+ return ColorCache::getFront((int)MessageLevel::Message);
+ }
+ return ColorCache::getFront((int)level);
+ }
+
+ QColor getBack(MessageLevel::Enum level)
+ {
+ if (level == MessageLevel::Fatal) {
+ return QColor(Qt::black);
+ }
+ return QColor(Qt::transparent);
+ }
+};
diff --git a/meshmc/launcher/ui/GuiUtil.cpp b/meshmc/launcher/ui/GuiUtil.cpp
new file mode 100644
index 0000000000..eef8514767
--- /dev/null
+++ b/meshmc/launcher/ui/GuiUtil.cpp
@@ -0,0 +1,154 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "GuiUtil.h"
+
+#include <QClipboard>
+#include <QApplication>
+#include <QFileDialog>
+
+#include "ui/dialogs/ProgressDialog.h"
+#include "ui/dialogs/CustomMessageBox.h"
+#include "net/PasteUpload.h"
+
+#include "Application.h"
+#include <settings/SettingsObject.h>
+#include <DesktopServices.h>
+#include <BuildConfig.h>
+
+QString GuiUtil::uploadPaste(const QString& text, QWidget* parentWidget)
+{
+ ProgressDialog dialog(parentWidget);
+ auto APIKeySetting =
+ APPLICATION->settings()->get("PasteEEAPIKey").toString();
+ if (APIKeySetting == "meshmc") {
+ APIKeySetting = BuildConfig.PASTE_EE_KEY;
+ }
+ std::unique_ptr<PasteUpload> paste(
+ new PasteUpload(parentWidget, text, APIKeySetting));
+
+ if (!paste->validateText()) {
+ CustomMessageBox::selectable(
+ parentWidget, QObject::tr("Upload failed"),
+ QObject::tr(
+ "The log file is too big. You'll have to upload it manually."),
+ QMessageBox::Warning)
+ ->exec();
+ return QString();
+ }
+
+ dialog.execWithTask(paste.get());
+ if (!paste->wasSuccessful()) {
+ CustomMessageBox::selectable(parentWidget, QObject::tr("Upload failed"),
+ paste->failReason(), QMessageBox::Critical)
+ ->exec();
+ return QString();
+ } else {
+ const QString link = paste->pasteLink();
+ setClipboardText(link);
+ CustomMessageBox::selectable(
+ parentWidget, QObject::tr("Upload finished"),
+ QObject::tr("The <a href=\"%1\">link to the uploaded log</a> has "
+ "been placed in your clipboard.")
+ .arg(link),
+ QMessageBox::Information)
+ ->exec();
+ return link;
+ }
+}
+
+void GuiUtil::setClipboardText(const QString& text)
+{
+ QApplication::clipboard()->setText(text);
+}
+
+static QStringList BrowseForFileInternal(QString context, QString caption,
+ QString filter, QString defaultPath,
+ QWidget* parentWidget, bool single)
+{
+ static QMap<QString, QString> savedPaths;
+
+ QFileDialog w(parentWidget, caption);
+ QSet<QString> locations;
+ auto f = [&](QStandardPaths::StandardLocation l) {
+ QString location = QStandardPaths::writableLocation(l);
+ QFileInfo finfo(location);
+ if (!finfo.exists()) {
+ return;
+ }
+ locations.insert(location);
+ };
+ f(QStandardPaths::DesktopLocation);
+ f(QStandardPaths::DocumentsLocation);
+ f(QStandardPaths::DownloadLocation);
+ f(QStandardPaths::HomeLocation);
+ QList<QUrl> urls;
+ for (auto location : locations) {
+ urls.append(QUrl::fromLocalFile(location));
+ }
+ urls.append(QUrl::fromLocalFile(defaultPath));
+
+ w.setFileMode(single ? QFileDialog::ExistingFile
+ : QFileDialog::ExistingFiles);
+ w.setAcceptMode(QFileDialog::AcceptOpen);
+ w.setNameFilter(filter);
+
+ QString pathToOpen;
+ if (savedPaths.contains(context)) {
+ pathToOpen = savedPaths[context];
+ } else {
+ pathToOpen = defaultPath;
+ }
+ if (!pathToOpen.isEmpty()) {
+ QFileInfo finfo(pathToOpen);
+ if (finfo.exists() && finfo.isDir()) {
+ w.setDirectory(finfo.absoluteFilePath());
+ }
+ }
+
+ w.setSidebarUrls(urls);
+
+ if (w.exec()) {
+ savedPaths[context] = w.directory().absolutePath();
+ return w.selectedFiles();
+ }
+ savedPaths[context] = w.directory().absolutePath();
+ return {};
+}
+
+QString GuiUtil::BrowseForFile(QString context, QString caption, QString filter,
+ QString defaultPath, QWidget* parentWidget)
+{
+ auto resultList = BrowseForFileInternal(context, caption, filter,
+ defaultPath, parentWidget, true);
+ if (resultList.size()) {
+ return resultList[0];
+ }
+ return QString();
+}
+
+QStringList GuiUtil::BrowseForFiles(QString context, QString caption,
+ QString filter, QString defaultPath,
+ QWidget* parentWidget)
+{
+ return BrowseForFileInternal(context, caption, filter, defaultPath,
+ parentWidget, false);
+}
diff --git a/meshmc/launcher/ui/GuiUtil.h b/meshmc/launcher/ui/GuiUtil.h
new file mode 100644
index 0000000000..04e6c22c59
--- /dev/null
+++ b/meshmc/launcher/ui/GuiUtil.h
@@ -0,0 +1,34 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+namespace GuiUtil
+{
+ QString uploadPaste(const QString& text, QWidget* parentWidget);
+ void setClipboardText(const QString& text);
+ QStringList BrowseForFiles(QString context, QString caption, QString filter,
+ QString defaultPath, QWidget* parentWidget);
+ QString BrowseForFile(QString context, QString caption, QString filter,
+ QString defaultPath, QWidget* parentWidget);
+} // namespace GuiUtil
diff --git a/meshmc/launcher/ui/InstanceWindow.cpp b/meshmc/launcher/ui/InstanceWindow.cpp
new file mode 100644
index 0000000000..583530c0e4
--- /dev/null
+++ b/meshmc/launcher/ui/InstanceWindow.cpp
@@ -0,0 +1,258 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "InstanceWindow.h"
+#include "Application.h"
+
+#include <QScrollBar>
+#include <QMessageBox>
+#include <QHBoxLayout>
+#include <QPushButton>
+#include <qlayoutitem.h>
+#include <QCloseEvent>
+
+#include "ui/dialogs/CustomMessageBox.h"
+#include "ui/dialogs/ProgressDialog.h"
+#include "ui/widgets/PageContainer.h"
+
+#include "InstancePageProvider.h"
+
+#include "icons/IconList.h"
+
+InstanceWindow::InstanceWindow(InstancePtr instance, QWidget* parent)
+ : QMainWindow(parent), m_instance(instance)
+{
+ setAttribute(Qt::WA_DeleteOnClose);
+
+ auto icon = APPLICATION->icons()->getIcon(m_instance->iconKey());
+ QString windowTitle = tr("Console window for ") + m_instance->name();
+
+ // Set window properties
+ {
+ setWindowIcon(icon);
+ setWindowTitle(windowTitle);
+ }
+
+ // Add page container
+ {
+ auto provider = std::make_shared<InstancePageProvider>(m_instance);
+ m_container = new PageContainer(provider.get(), "console", this);
+ m_container->setParentContainer(this);
+ setCentralWidget(m_container);
+ setContentsMargins(0, 0, 0, 0);
+ }
+
+ // Add custom buttons to the page container layout.
+ {
+ auto horizontalLayout = new QHBoxLayout();
+ horizontalLayout->setObjectName(QStringLiteral("horizontalLayout"));
+ horizontalLayout->setContentsMargins(6, -1, 6, -1);
+
+ auto btnHelp = new QPushButton();
+ btnHelp->setText(tr("Help"));
+ horizontalLayout->addWidget(btnHelp);
+ connect(btnHelp, SIGNAL(clicked(bool)), m_container, SLOT(help()));
+
+ auto spacer = new QSpacerItem(40, 20, QSizePolicy::Expanding,
+ QSizePolicy::Minimum);
+ horizontalLayout->addSpacerItem(spacer);
+
+ m_killButton = new QPushButton();
+ horizontalLayout->addWidget(m_killButton);
+ connect(m_killButton, SIGNAL(clicked(bool)),
+ SLOT(on_btnKillMinecraft_clicked()));
+
+ m_launchOfflineButton = new QPushButton();
+ horizontalLayout->addWidget(m_launchOfflineButton);
+ m_launchOfflineButton->setText(tr("Launch Offline"));
+ updateLaunchButtons();
+ connect(m_launchOfflineButton, SIGNAL(clicked(bool)),
+ SLOT(on_btnLaunchMinecraftOffline_clicked()));
+
+ m_closeButton = new QPushButton();
+ m_closeButton->setText(tr("Close"));
+ horizontalLayout->addWidget(m_closeButton);
+ connect(m_closeButton, SIGNAL(clicked(bool)),
+ SLOT(on_closeButton_clicked()));
+
+ m_container->addButtons(horizontalLayout);
+ }
+
+ // restore window state
+ {
+ auto base64State =
+ APPLICATION->settings()->get("ConsoleWindowState").toByteArray();
+ restoreState(QByteArray::fromBase64(base64State));
+ auto base64Geometry =
+ APPLICATION->settings()->get("ConsoleWindowGeometry").toByteArray();
+ restoreGeometry(QByteArray::fromBase64(base64Geometry));
+ }
+
+ // set up instance and launch process recognition
+ {
+ auto launchTask = m_instance->getLaunchTask();
+ on_InstanceLaunchTask_changed(launchTask);
+ connect(m_instance.get(), &BaseInstance::launchTaskChanged, this,
+ &InstanceWindow::on_InstanceLaunchTask_changed);
+ connect(m_instance.get(), &BaseInstance::runningStatusChanged, this,
+ &InstanceWindow::on_RunningState_changed);
+ }
+
+ // set up instance destruction detection
+ {
+ connect(m_instance.get(), &BaseInstance::statusChanged, this,
+ &InstanceWindow::on_instanceStatusChanged);
+ }
+ show();
+}
+
+void InstanceWindow::on_instanceStatusChanged(BaseInstance::Status,
+ BaseInstance::Status newStatus)
+{
+ if (newStatus == BaseInstance::Status::Gone) {
+ m_doNotSave = true;
+ close();
+ }
+}
+
+void InstanceWindow::updateLaunchButtons()
+{
+ if (m_instance->isRunning()) {
+ m_launchOfflineButton->setEnabled(false);
+ m_killButton->setText(tr("Kill"));
+ m_killButton->setObjectName("killButton");
+ m_killButton->setToolTip(tr("Kill the running instance"));
+ } else if (!m_instance->canLaunch()) {
+ m_launchOfflineButton->setEnabled(false);
+ m_killButton->setText(tr("Launch"));
+ m_killButton->setObjectName("launchButton");
+ m_killButton->setToolTip(tr("Launch the instance"));
+ m_killButton->setEnabled(false);
+ } else {
+ m_launchOfflineButton->setEnabled(true);
+ m_killButton->setText(tr("Launch"));
+ m_killButton->setObjectName("launchButton");
+ m_killButton->setToolTip(tr("Launch the instance"));
+ }
+ // NOTE: this is a hack to force the button to recalculate its style
+ m_killButton->setStyleSheet("/* */");
+ m_killButton->setStyleSheet(QString());
+}
+
+void InstanceWindow::on_btnLaunchMinecraftOffline_clicked()
+{
+ APPLICATION->launch(m_instance, false, nullptr);
+}
+
+void InstanceWindow::on_InstanceLaunchTask_changed(
+ shared_qobject_ptr<LaunchTask> proc)
+{
+ m_proc = proc;
+}
+
+void InstanceWindow::on_RunningState_changed(bool running)
+{
+ updateLaunchButtons();
+ m_container->refreshContainer();
+ if (running) {
+ selectPage("log");
+ }
+}
+
+void InstanceWindow::on_closeButton_clicked()
+{
+ close();
+}
+
+void InstanceWindow::closeEvent(QCloseEvent* event)
+{
+ bool proceed = true;
+ if (!m_doNotSave) {
+ proceed &= m_container->prepareToClose();
+ }
+
+ if (!proceed) {
+ return;
+ }
+
+ APPLICATION->settings()->set("ConsoleWindowState", saveState().toBase64());
+ APPLICATION->settings()->set("ConsoleWindowGeometry",
+ saveGeometry().toBase64());
+ emit isClosing();
+ event->accept();
+}
+
+bool InstanceWindow::saveAll()
+{
+ return m_container->saveAll();
+}
+
+void InstanceWindow::on_btnKillMinecraft_clicked()
+{
+ if (m_instance->isRunning()) {
+ APPLICATION->kill(m_instance);
+ } else {
+ APPLICATION->launch(m_instance, true, nullptr);
+ }
+}
+
+QString InstanceWindow::instanceId()
+{
+ return m_instance->id();
+}
+
+bool InstanceWindow::selectPage(QString pageId)
+{
+ return m_container->selectPage(pageId);
+}
+
+void InstanceWindow::refreshContainer()
+{
+ m_container->refreshContainer();
+}
+
+InstanceWindow::~InstanceWindow() {}
+
+bool InstanceWindow::requestClose()
+{
+ if (m_container->prepareToClose()) {
+ close();
+ return true;
+ }
+ return false;
+}
diff --git a/meshmc/launcher/ui/InstanceWindow.h b/meshmc/launcher/ui/InstanceWindow.h
new file mode 100644
index 0000000000..a70cf127f2
--- /dev/null
+++ b/meshmc/launcher/ui/InstanceWindow.h
@@ -0,0 +1,99 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QMainWindow>
+#include <QSystemTrayIcon>
+
+#include "LaunchController.h"
+#include "launch/LaunchTask.h"
+
+#include "ui/pages/BasePageContainer.h"
+
+#include "QObjectPtr.h"
+
+class QPushButton;
+class PageContainer;
+class InstanceWindow : public QMainWindow, public BasePageContainer
+{
+ Q_OBJECT
+
+ public:
+ explicit InstanceWindow(InstancePtr proc, QWidget* parent = 0);
+ virtual ~InstanceWindow();
+
+ bool selectPage(QString pageId) override;
+ void refreshContainer() override;
+
+ QString instanceId();
+
+ // save all settings and changes (prepare for launch)
+ bool saveAll();
+
+ // request closing the window (from a page)
+ bool requestClose() override;
+
+ signals:
+ void isClosing();
+
+ private slots:
+ void on_closeButton_clicked();
+ void on_btnKillMinecraft_clicked();
+ void on_btnLaunchMinecraftOffline_clicked();
+
+ void on_InstanceLaunchTask_changed(shared_qobject_ptr<LaunchTask> proc);
+ void on_RunningState_changed(bool running);
+ void on_instanceStatusChanged(BaseInstance::Status,
+ BaseInstance::Status newStatus);
+
+ protected:
+ void closeEvent(QCloseEvent*) override;
+
+ private:
+ void updateLaunchButtons();
+
+ private:
+ shared_qobject_ptr<LaunchTask> m_proc;
+ InstancePtr m_instance;
+ bool m_doNotSave = false;
+ PageContainer* m_container = nullptr;
+ QPushButton* m_closeButton = nullptr;
+ QPushButton* m_killButton = nullptr;
+ QPushButton* m_launchOfflineButton = nullptr;
+};
diff --git a/meshmc/launcher/ui/MainWindow.cpp b/meshmc/launcher/ui/MainWindow.cpp
new file mode 100644
index 0000000000..2b0d3d4056
--- /dev/null
+++ b/meshmc/launcher/ui/MainWindow.cpp
@@ -0,0 +1,2024 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "Application.h"
+#include "BuildConfig.h"
+
+#include "MainWindow.h"
+#include "ui/themes/ThemeManager.h"
+
+#include <QtCore/QVariant>
+#include <QtCore/QUrl>
+#include <QtCore/QDir>
+#include <QtCore/QFileInfo>
+
+#include <QtGui/QKeyEvent>
+
+#include <QAction>
+#include <QtWidgets/QApplication>
+#include <QtWidgets/QButtonGroup>
+#include <QtWidgets/QHBoxLayout>
+#include <QtWidgets/QHeaderView>
+#include <QtWidgets/QMainWindow>
+#include <QtWidgets/QStatusBar>
+#include <QtWidgets/QToolBar>
+#include <QtWidgets/QWidget>
+#include <QtWidgets/QMenu>
+#include <QtWidgets/QMessageBox>
+#include <QtWidgets/QInputDialog>
+#include <QtWidgets/QLabel>
+#include <QtWidgets/QToolButton>
+#include <QtWidgets/QWidgetAction>
+#include <QtWidgets/QProgressDialog>
+#include <QShortcut>
+
+#include <BaseInstance.h>
+#include <InstanceList.h>
+#include <MMCZip.h>
+#include <icons/IconList.h>
+#include <java/JavaUtils.h>
+#include <java/JavaInstallList.h>
+#include <launch/LaunchTask.h>
+#include <minecraft/auth/AccountList.h>
+#include <SkinUtils.h>
+#include <BuildConfig.h>
+#include <net/NetJob.h>
+#include <net/Download.h>
+#include <news/NewsChecker.h>
+#include <notifications/NotificationChecker.h>
+#include <tools/BaseProfiler.h>
+#include <updater/DownloadTask.h>
+#include <updater/UpdateChecker.h>
+#include <DesktopServices.h>
+#include "InstanceWindow.h"
+#include "InstancePageProvider.h"
+#include "JavaCommon.h"
+#include "LaunchController.h"
+
+#include "ui/instanceview/InstanceProxyModel.h"
+#include "ui/instanceview/InstanceView.h"
+#include "ui/instanceview/InstanceDelegate.h"
+#include "ui/widgets/LabeledToolButton.h"
+#include "ui/dialogs/NewInstanceDialog.h"
+#include "ui/dialogs/ProgressDialog.h"
+#include "ui/dialogs/AboutDialog.h"
+#include "ui/dialogs/VersionSelectDialog.h"
+#include "ui/dialogs/CustomMessageBox.h"
+#include "ui/dialogs/IconPickerDialog.h"
+#include "ui/dialogs/CopyInstanceDialog.h"
+#include "ui/dialogs/UpdateDialog.h"
+#include "ui/dialogs/EditAccountDialog.h"
+#include "ui/dialogs/NotificationDialog.h"
+#include "ui/dialogs/ExportInstanceDialog.h"
+
+#include "UpdateController.h"
+#include "KonamiCode.h"
+
+#include "InstanceImportTask.h"
+#include "InstanceCopyTask.h"
+
+#include "MMCTime.h"
+
+namespace
+{
+ QString profileInUseFilter(const QString& profile, bool used)
+ {
+ if (used) {
+ return QObject::tr("%1 (in use)").arg(profile);
+ } else {
+ return profile;
+ }
+ }
+} // namespace
+
+// WHY: to hold the pre-translation strings together with the T pointer, so it
+// can be retranslated without a lot of ugly code
+template <typename T> class Translated
+{
+ public:
+ Translated() {}
+ Translated(QWidget* parent)
+ {
+ m_contained = new T(parent);
+ }
+ void setTooltipId(const char* tooltip)
+ {
+ m_tooltip = tooltip;
+ }
+ void setTextId(const char* text)
+ {
+ m_text = text;
+ }
+ operator T*()
+ {
+ return m_contained;
+ }
+ T* operator->()
+ {
+ return m_contained;
+ }
+ void retranslate()
+ {
+ if (m_text) {
+ QString result;
+ result = QApplication::translate("MainWindow", m_text);
+ if (result.contains("%1")) {
+ result = result.arg(BuildConfig.MESHMC_NAME);
+ }
+ m_contained->setText(result);
+ }
+ if (m_tooltip) {
+ QString result;
+ result = QApplication::translate("MainWindow", m_tooltip);
+ if (result.contains("%1")) {
+ result = result.arg(BuildConfig.MESHMC_NAME);
+ }
+ m_contained->setToolTip(result);
+ }
+ }
+
+ private:
+ T* m_contained = nullptr;
+ const char* m_text = nullptr;
+ const char* m_tooltip = nullptr;
+};
+using TranslatedAction = Translated<QAction>;
+using TranslatedToolButton = Translated<QToolButton>;
+
+class TranslatedToolbar
+{
+ public:
+ TranslatedToolbar() {}
+ TranslatedToolbar(QWidget* parent)
+ {
+ m_contained = new QToolBar(parent);
+ }
+ void setWindowTitleId(const char* title)
+ {
+ m_title = title;
+ }
+ operator QToolBar*()
+ {
+ return m_contained;
+ }
+ QToolBar* operator->()
+ {
+ return m_contained;
+ }
+ void retranslate()
+ {
+ if (m_title) {
+ m_contained->setWindowTitle(
+ QApplication::translate("MainWindow", m_title));
+ }
+ }
+
+ private:
+ QToolBar* m_contained = nullptr;
+ const char* m_title = nullptr;
+};
+
+class MainWindow::Ui
+{
+ public:
+ TranslatedAction actionAddInstance;
+ // TranslatedAction actionRefresh;
+ TranslatedAction actionCheckUpdate;
+ TranslatedAction actionSettings;
+ TranslatedAction actionMoreNews;
+ TranslatedAction actionManageAccounts;
+ TranslatedAction actionLaunchInstance;
+ TranslatedAction actionRenameInstance;
+ TranslatedAction actionChangeInstGroup;
+ TranslatedAction actionChangeInstIcon;
+ TranslatedAction actionEditInstNotes;
+ TranslatedAction actionEditInstance;
+ TranslatedAction actionWorlds;
+ TranslatedAction actionMods;
+ TranslatedAction actionViewSelectedInstFolder;
+ TranslatedAction actionViewSelectedMCFolder;
+ TranslatedAction actionViewSelectedModsFolder;
+ TranslatedAction actionDeleteInstance;
+ TranslatedAction actionConfig_Folder;
+ TranslatedAction actionCAT;
+ TranslatedAction actionCopyInstance;
+ TranslatedAction actionLaunchInstanceOffline;
+ TranslatedAction actionScreenshots;
+ TranslatedAction actionExportInstance;
+ QVector<TranslatedAction*> all_actions;
+
+ LabeledToolButton* renameButton = nullptr;
+ LabeledToolButton* changeIconButton = nullptr;
+
+ QMenu* foldersMenu = nullptr;
+ TranslatedToolButton foldersMenuButton;
+ TranslatedAction actionViewInstanceFolder;
+ TranslatedAction actionViewCentralModsFolder;
+
+ QMenu* helpMenu = nullptr;
+ TranslatedToolButton helpMenuButton;
+ TranslatedAction actionReportBug;
+ TranslatedAction actionDISCORD;
+ TranslatedAction actionREDDIT;
+ TranslatedAction actionAbout;
+
+ QVector<TranslatedToolButton*> all_toolbuttons;
+
+ QWidget* centralWidget = nullptr;
+ QHBoxLayout* horizontalLayout = nullptr;
+ QStatusBar* statusBar = nullptr;
+
+ TranslatedToolbar mainToolBar;
+ TranslatedToolbar instanceToolBar;
+ TranslatedToolbar newsToolBar;
+ QVector<TranslatedToolbar*> all_toolbars;
+ bool m_kill = false;
+
+ void updateLaunchAction()
+ {
+ if (m_kill) {
+ actionLaunchInstance.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Kill"));
+ actionLaunchInstance.setTooltipId(
+ QT_TRANSLATE_NOOP("MainWindow", "Kill the running instance"));
+ } else {
+ actionLaunchInstance.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Launch"));
+ actionLaunchInstance.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "Launch the selected instance."));
+ }
+ actionLaunchInstance.retranslate();
+ }
+ void setLaunchAction(bool kill)
+ {
+ m_kill = kill;
+ updateLaunchAction();
+ }
+
+ void createMainToolbar(QMainWindow* MainWindow)
+ {
+ mainToolBar = TranslatedToolbar(MainWindow);
+ mainToolBar->setObjectName(QStringLiteral("mainToolBar"));
+ mainToolBar->setMovable(false);
+ mainToolBar->setAllowedAreas(Qt::TopToolBarArea);
+ mainToolBar->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
+ mainToolBar->setFloatable(false);
+ mainToolBar.setWindowTitleId(
+ QT_TRANSLATE_NOOP("MainWindow", "Main Toolbar"));
+
+ actionAddInstance = TranslatedAction(MainWindow);
+ actionAddInstance->setObjectName(QStringLiteral("actionAddInstance"));
+ actionAddInstance->setIcon(APPLICATION->getThemedIcon("new"));
+ actionAddInstance.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Add Instance"));
+ actionAddInstance.setTooltipId(
+ QT_TRANSLATE_NOOP("MainWindow", "Add a new instance."));
+ all_actions.append(&actionAddInstance);
+ mainToolBar->addAction(actionAddInstance);
+
+ mainToolBar->addSeparator();
+
+ foldersMenu = new QMenu(MainWindow);
+ foldersMenu->setToolTipsVisible(true);
+
+ actionViewInstanceFolder = TranslatedAction(MainWindow);
+ actionViewInstanceFolder->setObjectName(
+ QStringLiteral("actionViewInstanceFolder"));
+ actionViewInstanceFolder->setIcon(
+ APPLICATION->getThemedIcon("viewfolder"));
+ actionViewInstanceFolder.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "View Instance Folder"));
+ actionViewInstanceFolder.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "Open the instance folder in a file browser."));
+ all_actions.append(&actionViewInstanceFolder);
+ foldersMenu->addAction(actionViewInstanceFolder);
+
+ actionViewCentralModsFolder = TranslatedAction(MainWindow);
+ actionViewCentralModsFolder->setObjectName(
+ QStringLiteral("actionViewCentralModsFolder"));
+ actionViewCentralModsFolder->setIcon(
+ APPLICATION->getThemedIcon("centralmods"));
+ actionViewCentralModsFolder.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "View Central Mods Folder"));
+ actionViewCentralModsFolder.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "Open the central mods folder in a file browser."));
+ all_actions.append(&actionViewCentralModsFolder);
+ foldersMenu->addAction(actionViewCentralModsFolder);
+
+ foldersMenuButton = TranslatedToolButton(MainWindow);
+ foldersMenuButton.setTextId(QT_TRANSLATE_NOOP("MainWindow", "Folders"));
+ foldersMenuButton.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "Open one of the folders shared between instances."));
+ foldersMenuButton->setMenu(foldersMenu);
+ foldersMenuButton->setPopupMode(QToolButton::InstantPopup);
+ foldersMenuButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
+ foldersMenuButton->setIcon(APPLICATION->getThemedIcon("viewfolder"));
+ foldersMenuButton->setFocusPolicy(Qt::NoFocus);
+ all_toolbuttons.append(&foldersMenuButton);
+ QWidgetAction* foldersButtonAction = new QWidgetAction(MainWindow);
+ foldersButtonAction->setDefaultWidget(foldersMenuButton);
+ mainToolBar->addAction(foldersButtonAction);
+
+ actionSettings = TranslatedAction(MainWindow);
+ actionSettings->setObjectName(QStringLiteral("actionSettings"));
+ actionSettings->setIcon(APPLICATION->getThemedIcon("settings"));
+ actionSettings->setMenuRole(QAction::PreferencesRole);
+ actionSettings.setTextId(QT_TRANSLATE_NOOP("MainWindow", "Settings"));
+ actionSettings.setTooltipId(
+ QT_TRANSLATE_NOOP("MainWindow", "Change settings."));
+ all_actions.append(&actionSettings);
+ mainToolBar->addAction(actionSettings);
+
+ helpMenu = new QMenu(MainWindow);
+ helpMenu->setToolTipsVisible(true);
+
+ if (!BuildConfig.BUG_TRACKER_URL.isEmpty()) {
+ actionReportBug = TranslatedAction(MainWindow);
+ actionReportBug->setObjectName(QStringLiteral("actionReportBug"));
+ actionReportBug->setIcon(APPLICATION->getThemedIcon("bug"));
+ actionReportBug.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Report a Bug"));
+ actionReportBug.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "Open the bug tracker to report a bug with %1."));
+ all_actions.append(&actionReportBug);
+ helpMenu->addAction(actionReportBug);
+ }
+
+ if (!BuildConfig.DISCORD_URL.isEmpty()) {
+ actionDISCORD = TranslatedAction(MainWindow);
+ actionDISCORD->setObjectName(QStringLiteral("actionDISCORD"));
+ actionDISCORD->setIcon(APPLICATION->getThemedIcon("discord"));
+ actionDISCORD.setTextId(QT_TRANSLATE_NOOP("MainWindow", "Discord"));
+ actionDISCORD.setTooltipId(
+ QT_TRANSLATE_NOOP("MainWindow", "Open %1 discord voice chat."));
+ all_actions.append(&actionDISCORD);
+ helpMenu->addAction(actionDISCORD);
+ }
+
+ if (!BuildConfig.SUBREDDIT_URL.isEmpty()) {
+ actionREDDIT = TranslatedAction(MainWindow);
+ actionREDDIT->setObjectName(QStringLiteral("actionREDDIT"));
+ actionREDDIT->setIcon(APPLICATION->getThemedIcon("reddit-alien"));
+ actionREDDIT.setTextId(QT_TRANSLATE_NOOP("MainWindow", "Reddit"));
+ actionREDDIT.setTooltipId(
+ QT_TRANSLATE_NOOP("MainWindow", "Open %1 subreddit."));
+ all_actions.append(&actionREDDIT);
+ helpMenu->addAction(actionREDDIT);
+ }
+
+ actionAbout = TranslatedAction(MainWindow);
+ actionAbout->setObjectName(QStringLiteral("actionAbout"));
+ actionAbout->setIcon(APPLICATION->getThemedIcon("about"));
+ actionAbout->setMenuRole(QAction::AboutRole);
+ actionAbout.setTextId(QT_TRANSLATE_NOOP("MainWindow", "About %1"));
+ actionAbout.setTooltipId(
+ QT_TRANSLATE_NOOP("MainWindow", "View information about %1."));
+ all_actions.append(&actionAbout);
+ helpMenu->addAction(actionAbout);
+
+ helpMenuButton = TranslatedToolButton(MainWindow);
+ helpMenuButton.setTextId(QT_TRANSLATE_NOOP("MainWindow", "Help"));
+ helpMenuButton.setTooltipId(
+ QT_TRANSLATE_NOOP("MainWindow", "Get help with %1 or Minecraft."));
+ helpMenuButton->setMenu(helpMenu);
+ helpMenuButton->setPopupMode(QToolButton::InstantPopup);
+ helpMenuButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
+ helpMenuButton->setIcon(APPLICATION->getThemedIcon("help"));
+ helpMenuButton->setFocusPolicy(Qt::NoFocus);
+ all_toolbuttons.append(&helpMenuButton);
+ QWidgetAction* helpButtonAction = new QWidgetAction(MainWindow);
+ helpButtonAction->setDefaultWidget(helpMenuButton);
+ mainToolBar->addAction(helpButtonAction);
+
+ if (BuildConfig.UPDATER_ENABLED) {
+ actionCheckUpdate = TranslatedAction(MainWindow);
+ actionCheckUpdate->setObjectName(
+ QStringLiteral("actionCheckUpdate"));
+ actionCheckUpdate->setIcon(
+ APPLICATION->getThemedIcon("checkupdate"));
+ actionCheckUpdate.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Update"));
+ actionCheckUpdate.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "Check for new updates for %1."));
+ all_actions.append(&actionCheckUpdate);
+ mainToolBar->addAction(actionCheckUpdate);
+ }
+
+ mainToolBar->addSeparator();
+
+ actionCAT = TranslatedAction(MainWindow);
+ actionCAT->setObjectName(QStringLiteral("actionCAT"));
+ actionCAT->setCheckable(true);
+ actionCAT->setIcon(APPLICATION->getThemedIcon("cat"));
+ actionCAT.setTextId(QT_TRANSLATE_NOOP("MainWindow", "Meow"));
+ actionCAT.setTooltipId(
+ QT_TRANSLATE_NOOP("MainWindow", "It's a fluffy kitty :3"));
+ actionCAT->setPriority(QAction::LowPriority);
+ all_actions.append(&actionCAT);
+ mainToolBar->addAction(actionCAT);
+
+ // profile menu and its actions
+ actionManageAccounts = TranslatedAction(MainWindow);
+ actionManageAccounts->setObjectName(
+ QStringLiteral("actionManageAccounts"));
+ actionManageAccounts.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Manage Accounts"));
+ // FIXME: no tooltip!
+ actionManageAccounts->setCheckable(false);
+ actionManageAccounts->setIcon(APPLICATION->getThemedIcon("accounts"));
+ all_actions.append(&actionManageAccounts);
+
+ all_toolbars.append(&mainToolBar);
+ MainWindow->addToolBar(Qt::TopToolBarArea, mainToolBar);
+ }
+
+ void createStatusBar(QMainWindow* MainWindow)
+ {
+ statusBar = new QStatusBar(MainWindow);
+ statusBar->setObjectName(QStringLiteral("statusBar"));
+ MainWindow->setStatusBar(statusBar);
+ }
+
+ void createNewsToolbar(QMainWindow* MainWindow)
+ {
+ newsToolBar = TranslatedToolbar(MainWindow);
+ newsToolBar->setObjectName(QStringLiteral("newsToolBar"));
+ newsToolBar->setMovable(false);
+ newsToolBar->setAllowedAreas(Qt::BottomToolBarArea);
+ newsToolBar->setIconSize(QSize(16, 16));
+ newsToolBar->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
+ newsToolBar->setFloatable(false);
+ newsToolBar->setWindowTitle(
+ QT_TRANSLATE_NOOP("MainWindow", "News Toolbar"));
+
+ actionMoreNews = TranslatedAction(MainWindow);
+ actionMoreNews->setObjectName(QStringLiteral("actionMoreNews"));
+ actionMoreNews->setIcon(APPLICATION->getThemedIcon("news"));
+ actionMoreNews.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "More news..."));
+ actionMoreNews.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow",
+ "Open the development blog to read more news about %1."));
+ all_actions.append(&actionMoreNews);
+ newsToolBar->addAction(actionMoreNews);
+
+ all_toolbars.append(&newsToolBar);
+ MainWindow->addToolBar(Qt::BottomToolBarArea, newsToolBar);
+ }
+
+ void createInstanceToolbar(QMainWindow* MainWindow)
+ {
+ instanceToolBar = TranslatedToolbar(MainWindow);
+ instanceToolBar->setObjectName(QStringLiteral("instanceToolBar"));
+ // disabled until we have an instance selected
+ instanceToolBar->setEnabled(false);
+ instanceToolBar->setAllowedAreas(Qt::LeftToolBarArea |
+ Qt::RightToolBarArea);
+ instanceToolBar->setToolButtonStyle(Qt::ToolButtonTextOnly);
+ instanceToolBar->setFloatable(false);
+ instanceToolBar->setWindowTitle(
+ QT_TRANSLATE_NOOP("MainWindow", "Instance Toolbar"));
+
+ // NOTE: not added to toolbar, but used for instance context menu (right
+ // click)
+ actionChangeInstIcon = TranslatedAction(MainWindow);
+ actionChangeInstIcon->setObjectName(
+ QStringLiteral("actionChangeInstIcon"));
+ actionChangeInstIcon->setIcon(QIcon(":/icons/instances/grass"));
+ actionChangeInstIcon->setIconVisibleInMenu(true);
+ actionChangeInstIcon.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Change Icon"));
+ actionChangeInstIcon.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "Change the selected instance's icon."));
+ all_actions.append(&actionChangeInstIcon);
+
+ changeIconButton = new LabeledToolButton(MainWindow);
+ changeIconButton->setObjectName(QStringLiteral("changeIconButton"));
+ changeIconButton->setIcon(APPLICATION->getThemedIcon("news"));
+ changeIconButton->setToolTip(actionChangeInstIcon->toolTip());
+ changeIconButton->setSizePolicy(QSizePolicy::Expanding,
+ QSizePolicy::Preferred);
+ instanceToolBar->addWidget(changeIconButton);
+
+ // NOTE: not added to toolbar, but used for instance context menu (right
+ // click)
+ actionRenameInstance = TranslatedAction(MainWindow);
+ actionRenameInstance->setObjectName(
+ QStringLiteral("actionRenameInstance"));
+ actionRenameInstance.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Rename"));
+ actionRenameInstance.setTooltipId(
+ QT_TRANSLATE_NOOP("MainWindow", "Rename the selected instance."));
+ all_actions.append(&actionRenameInstance);
+
+ // the rename label is inside the rename tool button
+ renameButton = new LabeledToolButton(MainWindow);
+ renameButton->setObjectName(QStringLiteral("renameButton"));
+ renameButton->setToolTip(actionRenameInstance->toolTip());
+ renameButton->setSizePolicy(QSizePolicy::Expanding,
+ QSizePolicy::Preferred);
+ instanceToolBar->addWidget(renameButton);
+
+ instanceToolBar->addSeparator();
+
+ actionLaunchInstance = TranslatedAction(MainWindow);
+ actionLaunchInstance->setObjectName(
+ QStringLiteral("actionLaunchInstance"));
+ all_actions.append(&actionLaunchInstance);
+ instanceToolBar->addAction(actionLaunchInstance);
+
+ actionLaunchInstanceOffline = TranslatedAction(MainWindow);
+ actionLaunchInstanceOffline->setObjectName(
+ QStringLiteral("actionLaunchInstanceOffline"));
+ actionLaunchInstanceOffline.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Launch Offline"));
+ actionLaunchInstanceOffline.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "Launch the selected instance in offline mode."));
+ all_actions.append(&actionLaunchInstanceOffline);
+ instanceToolBar->addAction(actionLaunchInstanceOffline);
+
+ instanceToolBar->addSeparator();
+
+ actionEditInstance = TranslatedAction(MainWindow);
+ actionEditInstance->setObjectName(QStringLiteral("actionEditInstance"));
+ actionEditInstance.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Edit Instance"));
+ actionEditInstance.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "Change the instance settings, mods and versions."));
+ all_actions.append(&actionEditInstance);
+ instanceToolBar->addAction(actionEditInstance);
+
+ actionEditInstNotes = TranslatedAction(MainWindow);
+ actionEditInstNotes->setObjectName(
+ QStringLiteral("actionEditInstNotes"));
+ actionEditInstNotes.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Edit Notes"));
+ actionEditInstNotes.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "Edit the notes for the selected instance."));
+ all_actions.append(&actionEditInstNotes);
+ instanceToolBar->addAction(actionEditInstNotes);
+
+ actionMods = TranslatedAction(MainWindow);
+ actionMods->setObjectName(QStringLiteral("actionMods"));
+ actionMods.setTextId(QT_TRANSLATE_NOOP("MainWindow", "View Mods"));
+ actionMods.setTooltipId(
+ QT_TRANSLATE_NOOP("MainWindow", "View the mods of this instance."));
+ all_actions.append(&actionMods);
+ instanceToolBar->addAction(actionMods);
+
+ actionWorlds = TranslatedAction(MainWindow);
+ actionWorlds->setObjectName(QStringLiteral("actionWorlds"));
+ actionWorlds.setTextId(QT_TRANSLATE_NOOP("MainWindow", "View Worlds"));
+ actionWorlds.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "View the worlds of this instance."));
+ all_actions.append(&actionWorlds);
+ instanceToolBar->addAction(actionWorlds);
+
+ actionScreenshots = TranslatedAction(MainWindow);
+ actionScreenshots->setObjectName(QStringLiteral("actionScreenshots"));
+ actionScreenshots.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Manage Screenshots"));
+ actionScreenshots.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "View and upload screenshots for this instance."));
+ all_actions.append(&actionScreenshots);
+ instanceToolBar->addAction(actionScreenshots);
+
+ actionChangeInstGroup = TranslatedAction(MainWindow);
+ actionChangeInstGroup->setObjectName(
+ QStringLiteral("actionChangeInstGroup"));
+ actionChangeInstGroup.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Change Group"));
+ actionChangeInstGroup.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "Change the selected instance's group."));
+ all_actions.append(&actionChangeInstGroup);
+ instanceToolBar->addAction(actionChangeInstGroup);
+
+ instanceToolBar->addSeparator();
+
+ actionViewSelectedMCFolder = TranslatedAction(MainWindow);
+ actionViewSelectedMCFolder->setObjectName(
+ QStringLiteral("actionViewSelectedMCFolder"));
+ actionViewSelectedMCFolder.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Minecraft Folder"));
+ actionViewSelectedMCFolder.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "Open the selected instance's minecraft folder in a "
+ "file browser."));
+ all_actions.append(&actionViewSelectedMCFolder);
+ instanceToolBar->addAction(actionViewSelectedMCFolder);
+
+ /*
+ actionViewSelectedModsFolder = TranslatedAction(MainWindow);
+ actionViewSelectedModsFolder->setObjectName(QStringLiteral("actionViewSelectedModsFolder"));
+ actionViewSelectedModsFolder.setTextId(QT_TRANSLATE_NOOP("MainWindow",
+ "Mods Folder"));
+ actionViewSelectedModsFolder.setTooltipId(QT_TRANSLATE_NOOP("MainWindow",
+ "Open the selected instance's mods folder in a file browser."));
+ all_actions.append(&actionViewSelectedModsFolder);
+ instanceToolBar->addAction(actionViewSelectedModsFolder);
+ */
+
+ actionConfig_Folder = TranslatedAction(MainWindow);
+ actionConfig_Folder->setObjectName(
+ QStringLiteral("actionConfig_Folder"));
+ actionConfig_Folder.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Config Folder"));
+ actionConfig_Folder.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow", "Open the instance's config folder."));
+ all_actions.append(&actionConfig_Folder);
+ instanceToolBar->addAction(actionConfig_Folder);
+
+ actionViewSelectedInstFolder = TranslatedAction(MainWindow);
+ actionViewSelectedInstFolder->setObjectName(
+ QStringLiteral("actionViewSelectedInstFolder"));
+ actionViewSelectedInstFolder.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Instance Folder"));
+ actionViewSelectedInstFolder.setTooltipId(QT_TRANSLATE_NOOP(
+ "MainWindow",
+ "Open the selected instance's root folder in a file browser."));
+ all_actions.append(&actionViewSelectedInstFolder);
+ instanceToolBar->addAction(actionViewSelectedInstFolder);
+
+ instanceToolBar->addSeparator();
+
+ actionExportInstance = TranslatedAction(MainWindow);
+ actionExportInstance->setObjectName(
+ QStringLiteral("actionExportInstance"));
+ actionExportInstance.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Export Instance"));
+ // FIXME: missing tooltip
+ all_actions.append(&actionExportInstance);
+ instanceToolBar->addAction(actionExportInstance);
+
+ actionDeleteInstance = TranslatedAction(MainWindow);
+ actionDeleteInstance->setObjectName(
+ QStringLiteral("actionDeleteInstance"));
+ actionDeleteInstance.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Delete"));
+ actionDeleteInstance.setTooltipId(
+ QT_TRANSLATE_NOOP("MainWindow", "Delete the selected instance."));
+ all_actions.append(&actionDeleteInstance);
+ instanceToolBar->addAction(actionDeleteInstance);
+
+ actionCopyInstance = TranslatedAction(MainWindow);
+ actionCopyInstance->setObjectName(QStringLiteral("actionCopyInstance"));
+ actionCopyInstance->setIcon(APPLICATION->getThemedIcon("copy"));
+ actionCopyInstance.setTextId(
+ QT_TRANSLATE_NOOP("MainWindow", "Copy Instance"));
+ actionCopyInstance.setTooltipId(
+ QT_TRANSLATE_NOOP("MainWindow", "Copy the selected instance."));
+ all_actions.append(&actionCopyInstance);
+ instanceToolBar->addAction(actionCopyInstance);
+
+ all_toolbars.append(&instanceToolBar);
+ MainWindow->addToolBar(Qt::RightToolBarArea, instanceToolBar);
+ }
+
+ void setupUi(QMainWindow* MainWindow)
+ {
+ if (MainWindow->objectName().isEmpty()) {
+ MainWindow->setObjectName(QStringLiteral("MainWindow"));
+ }
+ MainWindow->resize(800, 600);
+ MainWindow->setWindowIcon(APPLICATION->getThemedIcon("logo"));
+ MainWindow->setWindowTitle(BuildConfig.MESHMC_DISPLAYNAME);
+#ifndef QT_NO_ACCESSIBILITY
+ MainWindow->setAccessibleName(BuildConfig.MESHMC_NAME);
+#endif
+
+ createMainToolbar(MainWindow);
+
+ centralWidget = new QWidget(MainWindow);
+ centralWidget->setObjectName(QStringLiteral("centralWidget"));
+ horizontalLayout = new QHBoxLayout(centralWidget);
+ horizontalLayout->setSpacing(0);
+ horizontalLayout->setObjectName(QStringLiteral("horizontalLayout"));
+ horizontalLayout->setSizeConstraint(QLayout::SetDefaultConstraint);
+ horizontalLayout->setContentsMargins(0, 0, 0, 0);
+ MainWindow->setCentralWidget(centralWidget);
+
+ createStatusBar(MainWindow);
+ createNewsToolbar(MainWindow);
+ createInstanceToolbar(MainWindow);
+
+ retranslateUi(MainWindow);
+
+ QMetaObject::connectSlotsByName(MainWindow);
+
+ // Explicit connections for actions that connectSlotsByName can't
+ // auto-connect in Qt6
+ auto mainWin = qobject_cast<class MainWindow*>(MainWindow);
+ QObject::connect(actionREDDIT.operator->(), &QAction::triggered,
+ mainWin, &MainWindow::on_actionREDDIT_triggered);
+ QObject::connect(actionDISCORD.operator->(), &QAction::triggered,
+ mainWin, &MainWindow::on_actionDISCORD_triggered);
+ QObject::connect(actionReportBug.operator->(), &QAction::triggered,
+ mainWin, &MainWindow::on_actionReportBug_triggered);
+ } // setupUi
+
+ void retranslateUi(QMainWindow* MainWindow)
+ {
+ QString winTitle = tr("%1 - Version %2", "MeshMC - Version X")
+ .arg(BuildConfig.MESHMC_DISPLAYNAME,
+ BuildConfig.printableVersionString());
+ if (!BuildConfig.BUILD_PLATFORM.isEmpty()) {
+ winTitle += tr(" on %1", "on platform, as in operating system")
+ .arg(BuildConfig.BUILD_PLATFORM);
+ }
+ MainWindow->setWindowTitle(winTitle);
+ // all the actions
+ for (auto* item : all_actions) {
+ item->retranslate();
+ }
+ for (auto* item : all_toolbars) {
+ item->retranslate();
+ }
+ for (auto* item : all_toolbuttons) {
+ item->retranslate();
+ }
+ // submenu buttons
+ foldersMenuButton->setText(tr("Folders"));
+ helpMenuButton->setText(tr("Help"));
+ } // retranslateUi
+};
+
+MainWindow::MainWindow(QWidget* parent)
+ : QMainWindow(parent), ui(new MainWindow::Ui)
+{
+ ui->setupUi(this);
+
+ // OSX magic.
+ setUnifiedTitleAndToolBarOnMac(true);
+
+ // Global shortcuts
+ {
+ // FIXME: This is kinda weird. and bad. We need some kind of managed
+ // shutdown.
+ auto q = new QShortcut(QKeySequence::Quit, this);
+ connect(q, SIGNAL(activated()), qApp, SLOT(quit()));
+ }
+
+ // Konami Code
+ {
+ secretEventFilter = new KonamiCode(this);
+ connect(secretEventFilter, &KonamiCode::triggered, this,
+ &MainWindow::konamiTriggered);
+ }
+
+ // Add the news label to the news toolbar.
+ {
+ m_newsChecker.reset(
+ new NewsChecker(APPLICATION->network(), BuildConfig.NEWS_RSS_URL));
+ newsLabel = new QToolButton();
+ newsLabel->setIcon(APPLICATION->getThemedIcon("news"));
+ newsLabel->setSizePolicy(QSizePolicy::Expanding,
+ QSizePolicy::Preferred);
+ newsLabel->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
+ newsLabel->setFocusPolicy(Qt::NoFocus);
+ ui->newsToolBar->insertWidget(ui->actionMoreNews, newsLabel);
+ QObject::connect(newsLabel, &QAbstractButton::clicked, this,
+ &MainWindow::newsButtonClicked);
+ QObject::connect(m_newsChecker.get(), &NewsChecker::newsLoaded, this,
+ &MainWindow::updateNewsLabel);
+ updateNewsLabel();
+ }
+
+ // Create the instance list widget
+ {
+ view = new InstanceView(ui->centralWidget);
+
+ view->setSelectionMode(QAbstractItemView::SingleSelection);
+ // FIXME: leaks ListViewDelegate
+ view->setItemDelegate(new ListViewDelegate(this));
+ view->setFrameShape(QFrame::NoFrame);
+ // do not show ugly blue border on the mac
+ view->setAttribute(Qt::WA_MacShowFocusRect, false);
+
+ view->installEventFilter(this);
+ view->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(view, &QWidget::customContextMenuRequested, this,
+ &MainWindow::showInstanceContextMenu);
+ connect(view, &InstanceView::droppedURLs, this,
+ &MainWindow::droppedURLs, Qt::QueuedConnection);
+
+ proxymodel = new InstanceProxyModel(this);
+ proxymodel->setSourceModel(APPLICATION->instances().get());
+ proxymodel->sort(0);
+ connect(proxymodel, &InstanceProxyModel::dataChanged, this,
+ &MainWindow::instanceDataChanged);
+
+ view->setModel(proxymodel);
+ view->setSourceOfGroupCollapseStatus(
+ [](const QString& groupName) -> bool {
+ return APPLICATION->instances()->isGroupCollapsed(groupName);
+ });
+ connect(view, &InstanceView::groupStateChanged,
+ APPLICATION->instances().get(),
+ &InstanceList::on_GroupStateChanged);
+ ui->horizontalLayout->addWidget(view);
+ }
+ // The cat background
+ {
+ bool cat_enable = APPLICATION->settings()->get("TheCat").toBool();
+ ui->actionCAT->setChecked(cat_enable);
+ // NOTE: calling the operator like that is an ugly hack to appease
+ // ancient gcc...
+ connect(ui->actionCAT.operator->(), SIGNAL(toggled(bool)),
+ SLOT(onCatToggled(bool)));
+ setCatBackground(cat_enable);
+ }
+ // start instance when double-clicked
+ connect(view, &InstanceView::activated, this,
+ &MainWindow::instanceActivated);
+
+ // track the selection -- update the instance toolbar
+ connect(view->selectionModel(), &QItemSelectionModel::currentChanged, this,
+ &MainWindow::instanceChanged);
+
+ // track icon changes and update the toolbar!
+ connect(APPLICATION->icons().get(), &IconList::iconUpdated, this,
+ &MainWindow::iconUpdated);
+
+ // model reset -> selection is invalid. All the instance pointers are wrong.
+ connect(APPLICATION->instances().get(), &InstanceList::dataIsInvalid, this,
+ &MainWindow::selectionBad);
+
+ // handle newly added instances
+ connect(APPLICATION->instances().get(),
+ &InstanceList::instanceSelectRequest, this,
+ &MainWindow::instanceSelectRequest);
+
+ // When the global settings page closes, we want to know about it and update
+ // our state
+ connect(APPLICATION, &Application::globalSettingsClosed, this,
+ &MainWindow::globalSettingsClosed);
+
+ m_statusLeft = new QLabel(tr("No instance selected"), this);
+ m_statusCenter = new QLabel(tr("Total playtime: 0s"), this);
+ statusBar()->addPermanentWidget(m_statusLeft, 1);
+ statusBar()->addPermanentWidget(m_statusCenter, 0);
+
+ // Add "manage accounts" button, right align
+ QWidget* spacer = new QWidget();
+ spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+ ui->mainToolBar->addWidget(spacer);
+
+ accountMenu = new QMenu(this);
+
+ repopulateAccountsMenu();
+
+ accountMenuButton = new QToolButton(this);
+ accountMenuButton->setMenu(accountMenu);
+ accountMenuButton->setPopupMode(QToolButton::InstantPopup);
+ accountMenuButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
+ accountMenuButton->setIcon(APPLICATION->getThemedIcon("noaccount"));
+
+ QWidgetAction* accountMenuButtonAction = new QWidgetAction(this);
+ accountMenuButtonAction->setDefaultWidget(accountMenuButton);
+
+ ui->mainToolBar->addAction(accountMenuButtonAction);
+
+ // Update the menu when the active account changes.
+ // Shouldn't have to use lambdas here like this, but if I don't, the
+ // compiler throws a fit. Template hell sucks...
+ connect(APPLICATION->accounts().get(), &AccountList::defaultAccountChanged,
+ [this] { defaultAccountChanged(); });
+ connect(APPLICATION->accounts().get(), &AccountList::listChanged,
+ [this] { repopulateAccountsMenu(); });
+
+ // Show initial account
+ defaultAccountChanged();
+
+ // TODO: refresh accounts here?
+ // auto accounts = APPLICATION->accounts();
+
+ // load the news
+ {
+ m_newsChecker->reloadNews();
+ updateNewsLabel();
+ }
+
+ if (BuildConfig.UPDATER_ENABLED && UpdateChecker::isUpdaterSupported()) {
+ bool updatesAllowed = APPLICATION->updatesAreAllowed();
+ updatesAllowedChanged(updatesAllowed);
+
+ // NOTE: calling the operator like that is an ugly hack to appease
+ // ancient gcc...
+ connect(ui->actionCheckUpdate.operator->(), &QAction::triggered, this,
+ &MainWindow::checkForUpdates);
+
+ // set up the updater object.
+ auto updater = APPLICATION->updateChecker();
+ connect(updater.get(), &UpdateChecker::updateAvailable, this,
+ &MainWindow::updateAvailable);
+ connect(updater.get(), &UpdateChecker::noUpdateFound, this,
+ &MainWindow::updateNotAvailable);
+ // if automatic update checks are allowed, start one.
+ if (APPLICATION->settings()->get("AutoUpdate").toBool() &&
+ updatesAllowed) {
+ updater->checkForUpdate(false);
+ }
+ }
+
+ {
+ auto checker = new NotificationChecker();
+ checker->setNotificationsUrl(QUrl(BuildConfig.NOTIFICATION_URL));
+ checker->setApplicationChannel(BuildConfig.VERSION_CHANNEL);
+ checker->setApplicationPlatform(BuildConfig.BUILD_PLATFORM);
+ checker->setApplicationFullVersion(BuildConfig.FULL_VERSION_STR);
+ m_notificationChecker.reset(checker);
+ connect(m_notificationChecker.get(),
+ &NotificationChecker::notificationCheckFinished, this,
+ &MainWindow::notificationsChanged);
+ checker->checkForNotifications();
+ }
+
+ setSelectedInstanceById(
+ APPLICATION->settings()->get("SelectedInstance").toString());
+
+ // removing this looks stupid
+ view->setFocus();
+
+ retranslateUi();
+}
+
+void MainWindow::retranslateUi()
+{
+ auto accounts = APPLICATION->accounts();
+ MinecraftAccountPtr defaultAccount = accounts->defaultAccount();
+ if (defaultAccount) {
+ auto profileLabel = profileInUseFilter(defaultAccount->profileName(),
+ defaultAccount->isInUse());
+ accountMenuButton->setText(profileLabel);
+ } else {
+ accountMenuButton->setText(tr("Profiles"));
+ }
+
+ if (m_selectedInstance) {
+ m_statusLeft->setText(m_selectedInstance->getStatusbarDescription());
+ } else {
+ m_statusLeft->setText(tr("No instance selected"));
+ }
+
+ ui->retranslateUi(this);
+}
+
+MainWindow::~MainWindow() {}
+
+QMenu* MainWindow::createPopupMenu()
+{
+ QMenu* filteredMenu = QMainWindow::createPopupMenu();
+ filteredMenu->removeAction(ui->mainToolBar->toggleViewAction());
+ return filteredMenu;
+}
+
+void MainWindow::konamiTriggered()
+{
+ qDebug() << "Super Secret Mode ACTIVATED!";
+}
+
+void MainWindow::showInstanceContextMenu(const QPoint& pos)
+{
+ QList<QAction*> actions;
+
+ QAction* actionSep = new QAction("", this);
+ actionSep->setSeparator(true);
+
+ bool onInstance = view->indexAt(pos).isValid();
+ if (onInstance) {
+ actions = ui->instanceToolBar->actions();
+
+ // replace the change icon widget with an actual action
+ actions.replace(0, ui->actionChangeInstIcon);
+
+ // replace the rename widget with an actual action
+ actions.replace(1, ui->actionRenameInstance);
+
+ // add header
+ actions.prepend(actionSep);
+ QAction* actionVoid = new QAction(m_selectedInstance->name(), this);
+ actionVoid->setEnabled(false);
+ actions.prepend(actionVoid);
+ } else {
+ auto group = view->groupNameAt(pos);
+
+ QAction* actionVoid = new QAction(BuildConfig.MESHMC_NAME, this);
+ actionVoid->setEnabled(false);
+
+ QAction* actionCreateInstance =
+ new QAction(tr("Create instance"), this);
+ actionCreateInstance->setToolTip(ui->actionAddInstance->toolTip());
+ if (!group.isNull()) {
+ QVariantMap data;
+ data["group"] = group;
+ actionCreateInstance->setData(data);
+ }
+
+ connect(actionCreateInstance, SIGNAL(triggered(bool)),
+ SLOT(on_actionAddInstance_triggered()));
+
+ actions.prepend(actionSep);
+ actions.prepend(actionVoid);
+ actions.append(actionCreateInstance);
+ if (!group.isNull()) {
+ QAction* actionDeleteGroup =
+ new QAction(tr("Delete group '%1'").arg(group), this);
+ QVariantMap data;
+ data["group"] = group;
+ actionDeleteGroup->setData(data);
+ connect(actionDeleteGroup, SIGNAL(triggered(bool)),
+ SLOT(deleteGroup()));
+ actions.append(actionDeleteGroup);
+ }
+ }
+ QMenu myMenu;
+ myMenu.addActions(actions);
+ /*
+ if (onInstance)
+ myMenu.setEnabled(m_selectedInstance->canLaunch());
+ */
+ myMenu.exec(view->mapToGlobal(pos));
+}
+
+void MainWindow::updateToolsMenu()
+{
+ QToolButton* launchButton = dynamic_cast<QToolButton*>(
+ ui->instanceToolBar->widgetForAction(ui->actionLaunchInstance));
+ QToolButton* launchOfflineButton = dynamic_cast<QToolButton*>(
+ ui->instanceToolBar->widgetForAction(ui->actionLaunchInstanceOffline));
+
+ if (!m_selectedInstance || m_selectedInstance->isRunning()) {
+ ui->actionLaunchInstance->setMenu(nullptr);
+ ui->actionLaunchInstanceOffline->setMenu(nullptr);
+ launchButton->setPopupMode(QToolButton::InstantPopup);
+ launchOfflineButton->setPopupMode(QToolButton::InstantPopup);
+ return;
+ }
+
+ QMenu* launchMenu = ui->actionLaunchInstance->menu();
+ QMenu* launchOfflineMenu = ui->actionLaunchInstanceOffline->menu();
+ launchButton->setPopupMode(QToolButton::MenuButtonPopup);
+ launchOfflineButton->setPopupMode(QToolButton::MenuButtonPopup);
+ if (launchMenu) {
+ launchMenu->clear();
+ } else {
+ launchMenu = new QMenu(this);
+ }
+ if (launchOfflineMenu) {
+ launchOfflineMenu->clear();
+ } else {
+ launchOfflineMenu = new QMenu(this);
+ }
+
+ QAction* normalLaunch = launchMenu->addAction(tr("Launch"));
+ QAction* normalLaunchOffline =
+ launchOfflineMenu->addAction(tr("Launch Offline"));
+ connect(normalLaunch, &QAction::triggered,
+ [this]() { APPLICATION->launch(m_selectedInstance, true); });
+ connect(normalLaunchOffline, &QAction::triggered,
+ [this]() { APPLICATION->launch(m_selectedInstance, false); });
+ QString profilersTitle = tr("Profilers");
+ launchMenu->addSeparator()->setText(profilersTitle);
+ launchOfflineMenu->addSeparator()->setText(profilersTitle);
+ for (auto profiler : APPLICATION->profilers().values()) {
+ QAction* profilerAction = launchMenu->addAction(profiler->name());
+ QAction* profilerOfflineAction =
+ launchOfflineMenu->addAction(profiler->name());
+ QString error;
+ if (!profiler->check(&error)) {
+ profilerAction->setDisabled(true);
+ profilerOfflineAction->setDisabled(true);
+ QString profilerToolTip = tr("Profiler not setup correctly. Go "
+ "into settings, \"External Tools\".");
+ profilerAction->setToolTip(profilerToolTip);
+ profilerOfflineAction->setToolTip(profilerToolTip);
+ } else {
+ connect(profilerAction, &QAction::triggered, [this, profiler]() {
+ APPLICATION->launch(m_selectedInstance, true, profiler.get());
+ });
+ connect(profilerOfflineAction, &QAction::triggered,
+ [this, profiler]() {
+ APPLICATION->launch(m_selectedInstance, false,
+ profiler.get());
+ });
+ }
+ }
+ ui->actionLaunchInstance->setMenu(launchMenu);
+ ui->actionLaunchInstanceOffline->setMenu(launchOfflineMenu);
+}
+
+void MainWindow::repopulateAccountsMenu()
+{
+ accountMenu->clear();
+
+ auto accounts = APPLICATION->accounts();
+ MinecraftAccountPtr defaultAccount = accounts->defaultAccount();
+
+ QString active_profileId = "";
+ if (defaultAccount) {
+ // this can be called before accountMenuButton exists
+ if (accountMenuButton) {
+ auto profileLabel = profileInUseFilter(
+ defaultAccount->profileName(), defaultAccount->isInUse());
+ accountMenuButton->setText(profileLabel);
+ }
+ }
+
+ if (accounts->count() <= 0) {
+ QAction* action = new QAction(tr("No accounts added!"), this);
+ action->setEnabled(false);
+ accountMenu->addAction(action);
+ } else {
+ // TODO: Nicer way to iterate?
+ for (int i = 0; i < accounts->count(); i++) {
+ MinecraftAccountPtr account = accounts->at(i);
+ auto profileLabel =
+ profileInUseFilter(account->profileName(), account->isInUse());
+ QAction* action = new QAction(profileLabel, this);
+ action->setData(i);
+ action->setCheckable(true);
+ if (defaultAccount == account) {
+ action->setChecked(true);
+ }
+
+ auto face = account->getFace();
+ if (!face.isNull()) {
+ action->setIcon(face);
+ } else {
+ action->setIcon(APPLICATION->getThemedIcon("noaccount"));
+ }
+ accountMenu->addAction(action);
+ connect(action, SIGNAL(triggered(bool)),
+ SLOT(changeActiveAccount()));
+ }
+ }
+
+ accountMenu->addSeparator();
+
+ QAction* action = new QAction(tr("No Default Account"), this);
+ action->setCheckable(true);
+ action->setIcon(APPLICATION->getThemedIcon("noaccount"));
+ action->setData(-1);
+ if (!defaultAccount) {
+ action->setChecked(true);
+ }
+
+ accountMenu->addAction(action);
+ connect(action, SIGNAL(triggered(bool)), SLOT(changeActiveAccount()));
+
+ accountMenu->addSeparator();
+ accountMenu->addAction(ui->actionManageAccounts);
+}
+
+void MainWindow::updatesAllowedChanged(bool allowed)
+{
+ if (!BuildConfig.UPDATER_ENABLED || !UpdateChecker::isUpdaterSupported()) {
+ return;
+ }
+ ui->actionCheckUpdate->setEnabled(allowed);
+}
+
+/*
+ * Assumes the sender is a QAction
+ */
+void MainWindow::changeActiveAccount()
+{
+ QAction* sAction = (QAction*)sender();
+
+ // Profile's associated Mojang username
+ if (sAction->data().type() != QVariant::Type::Int)
+ return;
+
+ QVariant data = sAction->data();
+ bool valid = false;
+ int index = data.toInt(&valid);
+ if (!valid) {
+ index = -1;
+ }
+ auto accounts = APPLICATION->accounts();
+ accounts->setDefaultAccount(index == -1 ? nullptr : accounts->at(index));
+ defaultAccountChanged();
+}
+
+void MainWindow::defaultAccountChanged()
+{
+ repopulateAccountsMenu();
+
+ MinecraftAccountPtr account = APPLICATION->accounts()->defaultAccount();
+
+ // FIXME: this needs adjustment for MSA
+ if (account && account->profileName() != "") {
+ auto profileLabel =
+ profileInUseFilter(account->profileName(), account->isInUse());
+ accountMenuButton->setText(profileLabel);
+ auto face = account->getFace();
+ if (face.isNull()) {
+ accountMenuButton->setIcon(APPLICATION->getThemedIcon("noaccount"));
+ } else {
+ accountMenuButton->setIcon(face);
+ }
+ return;
+ }
+
+ // Set the icon to the "no account" icon.
+ accountMenuButton->setIcon(APPLICATION->getThemedIcon("noaccount"));
+ accountMenuButton->setText(tr("Profiles"));
+}
+
+bool MainWindow::eventFilter(QObject* obj, QEvent* ev)
+{
+ if (obj == view) {
+ if (ev->type() == QEvent::KeyPress) {
+ secretEventFilter->input(ev);
+ QKeyEvent* keyEvent = static_cast<QKeyEvent*>(ev);
+ switch (keyEvent->key()) {
+ /*
+ case Qt::Key_Enter:
+ case Qt::Key_Return:
+ activateInstance(m_selectedInstance);
+ return true;
+ */
+ case Qt::Key_Delete:
+ on_actionDeleteInstance_triggered();
+ return true;
+ case Qt::Key_F5:
+ refreshInstances();
+ return true;
+ case Qt::Key_F2:
+ on_actionRenameInstance_triggered();
+ return true;
+ default:
+ break;
+ }
+ }
+ }
+ return QMainWindow::eventFilter(obj, ev);
+}
+
+void MainWindow::updateNewsLabel()
+{
+ if (m_newsChecker->isLoadingNews()) {
+ newsLabel->setText(tr("Loading news..."));
+ newsLabel->setEnabled(false);
+ } else {
+ QList<NewsEntryPtr> entries = m_newsChecker->getNewsEntries();
+ if (entries.length() > 0) {
+ newsLabel->setText(entries[0]->title);
+ newsLabel->setEnabled(true);
+ } else {
+ newsLabel->setText(tr("No news available."));
+ newsLabel->setEnabled(false);
+ }
+ }
+}
+
+void MainWindow::updateAvailable(UpdateAvailableStatus status)
+{
+ if (!APPLICATION->updatesAreAllowed()) {
+ updateNotAvailable();
+ return;
+ }
+ UpdateDialog dlg(true, status, this);
+ UpdateAction action = (UpdateAction)dlg.exec();
+ switch (action) {
+ case UPDATE_LATER:
+ qDebug() << "Update will be installed later.";
+ break;
+ case UPDATE_NOW:
+ if (!status.downloadUrl.isEmpty()) {
+ APPLICATION->updateIsRunning(true);
+ UpdateController controller(this, APPLICATION->root(),
+ status.downloadUrl);
+ if (controller.startUpdate()) {
+ // The updater binary has been launched; quit the main app
+ // so the updater can overwrite its files.
+ QCoreApplication::quit();
+ }
+ APPLICATION->updateIsRunning(false);
+ } else {
+ CustomMessageBox::selectable(
+ this, tr("No Download URL"),
+ tr("An update to version %1 is available, but no download "
+ "URL "
+ "was found for your platform (%2).\n"
+ "Please visit the project website to download it "
+ "manually.")
+ .arg(status.version, BuildConfig.BUILD_ARTIFACT),
+ QMessageBox::Information)
+ ->show();
+ }
+ break;
+ }
+}
+
+void MainWindow::updateNotAvailable()
+{
+ UpdateDialog dlg(false, {}, this);
+ dlg.exec();
+}
+
+QList<int> stringToIntList(const QString& string)
+{
+ QStringList split = string.split(',', Qt::SkipEmptyParts);
+ QList<int> out;
+ for (int i = 0; i < split.size(); ++i) {
+ out.append(split.at(i).toInt());
+ }
+ return out;
+}
+QString intListToString(const QList<int>& list)
+{
+ QStringList slist;
+ for (int i = 0; i < list.size(); ++i) {
+ slist.append(QString::number(list.at(i)));
+ }
+ return slist.join(',');
+}
+void MainWindow::notificationsChanged()
+{
+ QList<NotificationChecker::NotificationEntry> entries =
+ m_notificationChecker->notificationEntries();
+ QList<int> shownNotifications = stringToIntList(
+ APPLICATION->settings()->get("ShownNotifications").toString());
+ for (auto it = entries.begin(); it != entries.end(); ++it) {
+ NotificationChecker::NotificationEntry entry = *it;
+ if (!shownNotifications.contains(entry.id)) {
+ NotificationDialog dialog(entry, this);
+ if (dialog.exec() == NotificationDialog::DontShowAgain) {
+ shownNotifications.append(entry.id);
+ }
+ }
+ }
+ APPLICATION->settings()->set("ShownNotifications",
+ intListToString(shownNotifications));
+}
+
+void MainWindow::downloadUpdates(UpdateAvailableStatus status)
+{
+ // Kept as a stub — actual update installation is now done by the separate
+ // meshmc-updater binary launched from updateAvailable().
+ Q_UNUSED(status)
+}
+
+void MainWindow::onCatToggled(bool state)
+{
+ setCatBackground(state);
+ APPLICATION->settings()->set("TheCat", state);
+}
+
+void MainWindow::setCatBackground(bool enabled)
+{
+ if (enabled) {
+ QString catPath = APPLICATION->themeManager()->getCatPack();
+ view->setStyleSheet(QString(R"(
+InstanceView
+{
+ background-image: url(%1);
+ background-attachment: fixed;
+ background-clip: padding;
+ background-position: top right;
+ background-repeat: none;
+ background-color:palette(base);
+})")
+ .arg(catPath));
+ } else {
+ view->setStyleSheet(QString());
+ }
+}
+
+void MainWindow::runModalTask(Task* task)
+{
+ connect(task, &Task::failed, [this](QString reason) {
+ 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();
+ }
+ });
+ ProgressDialog loadDialog(this);
+ loadDialog.setSkipButton(true, tr("Abort"));
+ loadDialog.execWithTask(task);
+}
+
+void MainWindow::instanceFromInstanceTask(InstanceTask* rawTask)
+{
+ unique_qobject_ptr<Task> task(
+ APPLICATION->instances()->wrapInstanceTask(rawTask));
+ runModalTask(task.get());
+}
+
+void MainWindow::on_actionCopyInstance_triggered()
+{
+ if (!m_selectedInstance)
+ return;
+
+ CopyInstanceDialog copyInstDlg(m_selectedInstance, this);
+ if (!copyInstDlg.exec())
+ return;
+
+ auto copyTask =
+ new InstanceCopyTask(m_selectedInstance, copyInstDlg.shouldCopySaves(),
+ copyInstDlg.shouldKeepPlaytime());
+ copyTask->setName(copyInstDlg.instName());
+ copyTask->setGroup(copyInstDlg.instGroup());
+ copyTask->setIcon(copyInstDlg.iconKey());
+ unique_qobject_ptr<Task> task(
+ APPLICATION->instances()->wrapInstanceTask(copyTask));
+ runModalTask(task.get());
+}
+
+void MainWindow::finalizeInstance(InstancePtr inst)
+{
+ view->updateGeometries();
+ setSelectedInstanceById(inst->id());
+ if (APPLICATION->accounts()->anyAccountIsValid()) {
+ ProgressDialog loadDialog(this);
+ auto update = inst->createUpdateTask(Net::Mode::Online);
+ connect(update.get(), &Task::failed, [this](QString reason) {
+ QString error = QString("Instance load failed: %1").arg(reason);
+ CustomMessageBox::selectable(this, tr("Error"), error,
+ QMessageBox::Warning)
+ ->show();
+ });
+ if (update) {
+ loadDialog.setSkipButton(true, tr("Abort"));
+ loadDialog.execWithTask(update.get());
+ }
+ } else {
+ CustomMessageBox::selectable(
+ this, tr("Error"),
+ tr("MeshMC cannot download Minecraft or update instances unless "
+ "you have at least "
+ "one account added.\nPlease add your Mojang or Minecraft "
+ "account."),
+ QMessageBox::Warning)
+ ->show();
+ }
+}
+
+void MainWindow::addInstance(QString url)
+{
+ QString groupName;
+ do {
+ QObject* obj = sender();
+ if (!obj)
+ break;
+ QAction* action = qobject_cast<QAction*>(obj);
+ if (!action)
+ break;
+ auto map = action->data().toMap();
+ if (!map.contains("group"))
+ break;
+ groupName = map["group"].toString();
+ } while (0);
+
+ if (groupName.isEmpty()) {
+ groupName = APPLICATION->settings()
+ ->get("LastUsedGroupForNewInstance")
+ .toString();
+ }
+
+ NewInstanceDialog newInstDlg(groupName, url, this);
+ if (!newInstDlg.exec())
+ return;
+
+ APPLICATION->settings()->set("LastUsedGroupForNewInstance",
+ newInstDlg.instGroup());
+
+ InstanceTask* creationTask = newInstDlg.extractTask();
+ if (creationTask) {
+ instanceFromInstanceTask(creationTask);
+ }
+}
+
+void MainWindow::on_actionAddInstance_triggered()
+{
+ addInstance();
+}
+
+void MainWindow::droppedURLs(QList<QUrl> urls)
+{
+ for (auto& url : urls) {
+ if (url.isLocalFile()) {
+ addInstance(url.toLocalFile());
+ } else {
+ addInstance(url.toString());
+ }
+ // Only process one dropped file...
+ break;
+ }
+}
+
+void MainWindow::on_actionREDDIT_triggered()
+{
+ DesktopServices::openUrl(QUrl(BuildConfig.SUBREDDIT_URL));
+}
+
+void MainWindow::on_actionDISCORD_triggered()
+{
+ DesktopServices::openUrl(QUrl(BuildConfig.DISCORD_URL));
+}
+
+void MainWindow::on_actionChangeInstIcon_triggered()
+{
+ if (!m_selectedInstance)
+ return;
+
+ IconPickerDialog dlg(this);
+ dlg.execWithSelection(m_selectedInstance->iconKey());
+ if (dlg.result() == QDialog::Accepted) {
+ m_selectedInstance->setIconKey(dlg.selectedIconKey);
+ auto icon = APPLICATION->icons()->getIcon(dlg.selectedIconKey);
+ ui->actionChangeInstIcon->setIcon(icon);
+ ui->changeIconButton->setIcon(icon);
+ }
+}
+
+void MainWindow::iconUpdated(QString icon)
+{
+ if (icon == m_currentInstIcon) {
+ auto icon = APPLICATION->icons()->getIcon(m_currentInstIcon);
+ ui->actionChangeInstIcon->setIcon(icon);
+ ui->changeIconButton->setIcon(icon);
+ }
+}
+
+void MainWindow::updateInstanceToolIcon(QString new_icon)
+{
+ m_currentInstIcon = new_icon;
+ auto icon = APPLICATION->icons()->getIcon(m_currentInstIcon);
+ ui->actionChangeInstIcon->setIcon(icon);
+ ui->changeIconButton->setIcon(icon);
+}
+
+void MainWindow::setSelectedInstanceById(const QString& id)
+{
+ if (id.isNull())
+ return;
+ const QModelIndex index =
+ APPLICATION->instances()->getInstanceIndexById(id);
+ if (index.isValid()) {
+ QModelIndex selectionIndex = proxymodel->mapFromSource(index);
+ view->selectionModel()->setCurrentIndex(
+ selectionIndex, QItemSelectionModel::ClearAndSelect);
+ updateStatusCenter();
+ }
+}
+
+void MainWindow::on_actionChangeInstGroup_triggered()
+{
+ if (!m_selectedInstance)
+ return;
+
+ bool ok = false;
+ InstanceId instId = m_selectedInstance->id();
+ QString name(APPLICATION->instances()->getInstanceGroup(instId));
+ auto groups = APPLICATION->instances()->getGroups();
+ groups.insert(0, "");
+ groups.sort(Qt::CaseInsensitive);
+ int foo = groups.indexOf(name);
+
+ name = QInputDialog::getItem(this, tr("Group name"),
+ tr("Enter a new group name."), groups, foo,
+ true, &ok);
+ name = name.simplified();
+ if (ok) {
+ APPLICATION->instances()->setInstanceGroup(instId, name);
+ }
+}
+
+void MainWindow::deleteGroup()
+{
+ QObject* obj = sender();
+ if (!obj)
+ return;
+ QAction* action = qobject_cast<QAction*>(obj);
+ if (!action)
+ return;
+ auto map = action->data().toMap();
+ if (!map.contains("group"))
+ return;
+ QString groupName = map["group"].toString();
+ if (!groupName.isEmpty()) {
+ auto reply = QMessageBox::question(
+ this, tr("Delete group"),
+ tr("Are you sure you want to delete the group %1").arg(groupName),
+ QMessageBox::Yes | QMessageBox::No);
+ if (reply == QMessageBox::Yes) {
+ APPLICATION->instances()->deleteGroup(groupName);
+ }
+ }
+}
+
+void MainWindow::on_actionViewInstanceFolder_triggered()
+{
+ QString str = APPLICATION->settings()->get("InstanceDir").toString();
+ DesktopServices::openDirectory(str);
+}
+
+void MainWindow::refreshInstances()
+{
+ APPLICATION->instances()->loadList();
+}
+
+void MainWindow::on_actionViewCentralModsFolder_triggered()
+{
+ DesktopServices::openDirectory(
+ APPLICATION->settings()->get("CentralModsDir").toString(), true);
+}
+
+void MainWindow::on_actionConfig_Folder_triggered()
+{
+ if (m_selectedInstance) {
+ QString str = m_selectedInstance->instanceConfigFolder();
+ DesktopServices::openDirectory(QDir(str).absolutePath());
+ }
+}
+
+void MainWindow::checkForUpdates()
+{
+ if (BuildConfig.UPDATER_ENABLED && UpdateChecker::isUpdaterSupported()) {
+ auto updater = APPLICATION->updateChecker();
+ updater->checkForUpdate(true);
+ } else {
+ qWarning() << "Updater not set up or not supported on this platform. "
+ "Cannot check for updates.";
+ }
+}
+
+void MainWindow::on_actionSettings_triggered()
+{
+ APPLICATION->ShowGlobalSettings(this, "global-settings");
+}
+
+void MainWindow::globalSettingsClosed()
+{
+ // FIXME: quick HACK to make this work. improve, optimize.
+ APPLICATION->instances()->loadList();
+ proxymodel->invalidate();
+ proxymodel->sort(0);
+ updateToolsMenu();
+ updateStatusCenter();
+ update();
+}
+
+void MainWindow::on_actionInstanceSettings_triggered()
+{
+ APPLICATION->showInstanceWindow(m_selectedInstance, "settings");
+}
+
+void MainWindow::on_actionEditInstNotes_triggered()
+{
+ APPLICATION->showInstanceWindow(m_selectedInstance, "notes");
+}
+
+void MainWindow::on_actionWorlds_triggered()
+{
+ APPLICATION->showInstanceWindow(m_selectedInstance, "worlds");
+}
+
+void MainWindow::on_actionMods_triggered()
+{
+ APPLICATION->showInstanceWindow(m_selectedInstance, "mods");
+}
+
+void MainWindow::on_actionEditInstance_triggered()
+{
+ APPLICATION->showInstanceWindow(m_selectedInstance);
+}
+
+void MainWindow::on_actionScreenshots_triggered()
+{
+ APPLICATION->showInstanceWindow(m_selectedInstance, "screenshots");
+}
+
+void MainWindow::on_actionManageAccounts_triggered()
+{
+ APPLICATION->ShowGlobalSettings(this, "accounts");
+}
+
+void MainWindow::on_actionReportBug_triggered()
+{
+ DesktopServices::openUrl(QUrl(BuildConfig.BUG_TRACKER_URL));
+}
+
+void MainWindow::on_actionMoreNews_triggered()
+{
+ DesktopServices::openUrl(
+ QUrl("https://projecttick.org/product/meshmc/news"));
+}
+
+void MainWindow::newsButtonClicked()
+{
+ QList<NewsEntryPtr> entries = m_newsChecker->getNewsEntries();
+ if (entries.count() > 0) {
+ DesktopServices::openUrl(QUrl(entries[0]->link));
+ } else {
+ DesktopServices::openUrl(
+ QUrl("https://projecttick.org/product/meshmc/news"));
+ }
+}
+
+void MainWindow::on_actionAbout_triggered()
+{
+ AboutDialog dialog(this);
+ dialog.exec();
+}
+
+void MainWindow::on_actionDeleteInstance_triggered()
+{
+ if (!m_selectedInstance) {
+ return;
+ }
+ auto id = m_selectedInstance->id();
+ auto response = CustomMessageBox::selectable(
+ this, tr("CAREFUL!"),
+ tr("About to delete: %1\nThis is permanent and will "
+ "completely delete the instance.\n\nAre you sure?")
+ .arg(m_selectedInstance->name()),
+ QMessageBox::Warning,
+ QMessageBox::Yes | QMessageBox::No, QMessageBox::No)
+ ->exec();
+ if (response == QMessageBox::Yes) {
+ APPLICATION->instances()->deleteInstance(id);
+ }
+}
+
+void MainWindow::on_actionExportInstance_triggered()
+{
+ if (m_selectedInstance) {
+ ExportInstanceDialog dlg(m_selectedInstance, this);
+ dlg.exec();
+ }
+}
+
+void MainWindow::on_actionRenameInstance_triggered()
+{
+ if (m_selectedInstance) {
+ view->edit(view->currentIndex());
+ }
+}
+
+void MainWindow::on_actionViewSelectedInstFolder_triggered()
+{
+ if (m_selectedInstance) {
+ QString str = m_selectedInstance->instanceRoot();
+ DesktopServices::openDirectory(QDir(str).absolutePath());
+ }
+}
+
+void MainWindow::on_actionViewSelectedMCFolder_triggered()
+{
+ if (m_selectedInstance) {
+ QString str = m_selectedInstance->gameRoot();
+ if (!FS::ensureFilePathExists(str)) {
+ // TODO: report error
+ return;
+ }
+ DesktopServices::openDirectory(QDir(str).absolutePath());
+ }
+}
+
+void MainWindow::on_actionViewSelectedModsFolder_triggered()
+{
+ if (m_selectedInstance) {
+ QString str = m_selectedInstance->modsRoot();
+ if (!FS::ensureFilePathExists(str)) {
+ // TODO: report error
+ return;
+ }
+ DesktopServices::openDirectory(QDir(str).absolutePath());
+ }
+}
+
+void MainWindow::closeEvent(QCloseEvent* event)
+{
+ // Save the window state and geometry.
+ APPLICATION->settings()->set("MainWindowState", saveState().toBase64());
+ APPLICATION->settings()->set("MainWindowGeometry",
+ saveGeometry().toBase64());
+ event->accept();
+ emit isClosing();
+}
+
+void MainWindow::changeEvent(QEvent* event)
+{
+ if (event->type() == QEvent::LanguageChange) {
+ retranslateUi();
+ }
+ QMainWindow::changeEvent(event);
+}
+
+void MainWindow::instanceActivated(QModelIndex index)
+{
+ if (!index.isValid())
+ return;
+ QString id = index.data(InstanceList::InstanceIDRole).toString();
+ InstancePtr inst = APPLICATION->instances()->getInstanceById(id);
+ if (!inst)
+ return;
+
+ activateInstance(inst);
+}
+
+void MainWindow::on_actionLaunchInstance_triggered()
+{
+ if (!m_selectedInstance) {
+ return;
+ }
+ if (m_selectedInstance->isRunning()) {
+ APPLICATION->kill(m_selectedInstance);
+ } else {
+ APPLICATION->launch(m_selectedInstance);
+ }
+}
+
+void MainWindow::activateInstance(InstancePtr instance)
+{
+ APPLICATION->launch(instance);
+}
+
+void MainWindow::on_actionLaunchInstanceOffline_triggered()
+{
+ if (m_selectedInstance) {
+ APPLICATION->launch(m_selectedInstance, false);
+ }
+}
+
+void MainWindow::taskEnd()
+{
+ QObject* sender = QObject::sender();
+ if (sender == m_versionLoadTask)
+ m_versionLoadTask = NULL;
+
+ sender->deleteLater();
+}
+
+void MainWindow::startTask(Task* task)
+{
+ connect(task, SIGNAL(succeeded()), SLOT(taskEnd()));
+ connect(task, SIGNAL(failed(QString)), SLOT(taskEnd()));
+ task->start();
+}
+
+void MainWindow::instanceChanged(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ if (!current.isValid()) {
+ APPLICATION->settings()->set("SelectedInstance", QString());
+ selectionBad();
+ return;
+ }
+ QString id = current.data(InstanceList::InstanceIDRole).toString();
+ m_selectedInstance = APPLICATION->instances()->getInstanceById(id);
+ if (m_selectedInstance) {
+ ui->instanceToolBar->setEnabled(true);
+ if (m_selectedInstance->isRunning()) {
+ ui->actionLaunchInstance->setEnabled(true);
+ ui->setLaunchAction(true);
+ } else {
+ ui->actionLaunchInstance->setEnabled(
+ m_selectedInstance->canLaunch());
+ ui->setLaunchAction(false);
+ }
+ ui->actionLaunchInstanceOffline->setEnabled(
+ m_selectedInstance->canLaunch());
+ ui->actionExportInstance->setEnabled(m_selectedInstance->canExport());
+ ui->renameButton->setText(m_selectedInstance->name());
+ m_statusLeft->setText(m_selectedInstance->getStatusbarDescription());
+ updateStatusCenter();
+ updateInstanceToolIcon(m_selectedInstance->iconKey());
+
+ updateToolsMenu();
+
+ APPLICATION->settings()->set("SelectedInstance",
+ m_selectedInstance->id());
+ } else {
+ ui->instanceToolBar->setEnabled(false);
+ APPLICATION->settings()->set("SelectedInstance", QString());
+ selectionBad();
+ return;
+ }
+}
+
+void MainWindow::instanceSelectRequest(QString id)
+{
+ setSelectedInstanceById(id);
+}
+
+void MainWindow::instanceDataChanged(const QModelIndex& topLeft,
+ const QModelIndex& bottomRight)
+{
+ auto current = view->selectionModel()->currentIndex();
+ QItemSelection test(topLeft, bottomRight);
+ if (test.contains(current)) {
+ instanceChanged(current, current);
+ }
+}
+
+void MainWindow::selectionBad()
+{
+ // start by reseting everything...
+ m_selectedInstance = nullptr;
+
+ statusBar()->clearMessage();
+ ui->instanceToolBar->setEnabled(false);
+ ui->renameButton->setText(tr("Rename Instance"));
+ updateInstanceToolIcon("grass");
+
+ // ...and then see if we can enable the previously selected instance
+ setSelectedInstanceById(
+ APPLICATION->settings()->get("SelectedInstance").toString());
+}
+
+void MainWindow::checkInstancePathForProblems()
+{
+ QString instanceFolder =
+ APPLICATION->settings()->get("InstanceDir").toString();
+ if (FS::checkProblemticPathJava(QDir(instanceFolder))) {
+ QMessageBox warning(this);
+ warning.setText(tr("Your instance folder contains \'!\' and this is "
+ "known to cause Java problems!"));
+ warning.setInformativeText(
+ tr("You have now two options: <br/>"
+ " - change the instance folder in the settings <br/>"
+ " - move this installation of %1 to a different folder")
+ .arg(BuildConfig.MESHMC_NAME));
+ warning.setDefaultButton(QMessageBox::Ok);
+ warning.exec();
+ }
+ auto tempFolderText = tr("This is a problem: <br/>"
+ " - MeshMC will likely be deleted without warning "
+ "by the operating system <br/>"
+ " - close MeshMC now and extract it to a real "
+ "location, not a temporary folder");
+ QString pathfoldername = QDir(instanceFolder).absolutePath();
+ if (pathfoldername.contains("Rar$", Qt::CaseInsensitive)) {
+ QMessageBox warning(this);
+ warning.setText(tr("Your instance folder contains \'Rar$\' - that "
+ "means you haven't extracted MeshMC archive!"));
+ warning.setInformativeText(tempFolderText);
+ warning.setDefaultButton(QMessageBox::Ok);
+ warning.exec();
+ } else if (pathfoldername.startsWith(QDir::tempPath()) ||
+ pathfoldername.contains("/TempState/")) {
+ QMessageBox warning(this);
+ warning.setText(
+ tr("Your instance folder is in a temporary folder: \'%1\'!")
+ .arg(QDir::tempPath()));
+ warning.setInformativeText(tempFolderText);
+ warning.setDefaultButton(QMessageBox::Ok);
+ warning.exec();
+ }
+}
+
+void MainWindow::updateStatusCenter()
+{
+ m_statusCenter->setVisible(
+ APPLICATION->settings()->get("ShowGlobalGameTime").toBool());
+
+ int timePlayed = APPLICATION->instances()->getTotalPlayTime();
+ if (timePlayed > 0) {
+ m_statusCenter->setText(
+ tr("Total playtime: %1").arg(Time::prettifyDuration(timePlayed)));
+ }
+}
diff --git a/meshmc/launcher/ui/MainWindow.h b/meshmc/launcher/ui/MainWindow.h
new file mode 100644
index 0000000000..3a9e62a9b2
--- /dev/null
+++ b/meshmc/launcher/ui/MainWindow.h
@@ -0,0 +1,249 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <memory>
+
+#include <QMainWindow>
+#include <QProcess>
+#include <QTimer>
+
+#include "BaseInstance.h"
+#include "minecraft/auth/MinecraftAccount.h"
+#include "net/NetJob.h"
+#include "updater/UpdateChecker.h"
+
+class LaunchController;
+class NewsChecker;
+class NotificationChecker;
+class QToolButton;
+class InstanceProxyModel;
+class LabeledToolButton;
+class QLabel;
+class MinecraftLauncher;
+class BaseProfilerFactory;
+class InstanceView;
+class KonamiCode;
+class InstanceTask;
+
+class MainWindow : public QMainWindow
+{
+ Q_OBJECT
+
+ class Ui;
+
+ public:
+ explicit MainWindow(QWidget* parent = 0);
+ ~MainWindow();
+
+ bool eventFilter(QObject* obj, QEvent* ev) override;
+ void closeEvent(QCloseEvent* event) override;
+ void changeEvent(QEvent* event) override;
+
+ void checkInstancePathForProblems();
+
+ void updatesAllowedChanged(bool allowed);
+
+ void droppedURLs(QList<QUrl> urls);
+ signals:
+ void isClosing();
+
+ protected:
+ QMenu* createPopupMenu() override;
+
+ private slots:
+ void onCatToggled(bool);
+
+ void on_actionAbout_triggered();
+
+ void on_actionAddInstance_triggered();
+
+ void on_actionREDDIT_triggered();
+
+ void on_actionDISCORD_triggered();
+
+ void on_actionCopyInstance_triggered();
+
+ void on_actionChangeInstGroup_triggered();
+
+ void on_actionChangeInstIcon_triggered();
+ void on_changeIconButton_clicked(bool)
+ {
+ on_actionChangeInstIcon_triggered();
+ }
+
+ void on_actionViewInstanceFolder_triggered();
+
+ void on_actionConfig_Folder_triggered();
+
+ void on_actionViewSelectedInstFolder_triggered();
+
+ void on_actionViewSelectedMCFolder_triggered();
+
+ void on_actionViewSelectedModsFolder_triggered();
+
+ void refreshInstances();
+
+ void on_actionViewCentralModsFolder_triggered();
+
+ void checkForUpdates();
+
+ void on_actionSettings_triggered();
+
+ void on_actionInstanceSettings_triggered();
+
+ void on_actionManageAccounts_triggered();
+
+ void on_actionReportBug_triggered();
+
+ void on_actionMoreNews_triggered();
+
+ void newsButtonClicked();
+
+ void on_actionLaunchInstance_triggered();
+
+ void on_actionLaunchInstanceOffline_triggered();
+
+ void on_actionDeleteInstance_triggered();
+
+ void deleteGroup();
+
+ void on_actionExportInstance_triggered();
+
+ void on_actionRenameInstance_triggered();
+ void on_renameButton_clicked(bool)
+ {
+ on_actionRenameInstance_triggered();
+ }
+
+ void on_actionEditInstance_triggered();
+
+ void on_actionEditInstNotes_triggered();
+
+ void on_actionMods_triggered();
+
+ void on_actionWorlds_triggered();
+
+ void on_actionScreenshots_triggered();
+
+ void taskEnd();
+
+ /**
+ * called when an icon is changed in the icon model.
+ */
+ void iconUpdated(QString);
+
+ void showInstanceContextMenu(const QPoint&);
+
+ void updateToolsMenu();
+
+ void instanceActivated(QModelIndex);
+
+ void instanceChanged(const QModelIndex& current,
+ const QModelIndex& previous);
+
+ void instanceSelectRequest(QString id);
+
+ void instanceDataChanged(const QModelIndex& topLeft,
+ const QModelIndex& bottomRight);
+
+ void selectionBad();
+
+ void startTask(Task* task);
+
+ void updateAvailable(UpdateAvailableStatus status);
+
+ void updateNotAvailable();
+
+ void notificationsChanged();
+
+ void defaultAccountChanged();
+
+ void changeActiveAccount();
+
+ void repopulateAccountsMenu();
+
+ void updateNewsLabel();
+
+ /*!
+ * Stub kept for source compatibility; actual installation is delegated to
+ * the meshmc-updater binary via UpdateController.
+ */
+ void downloadUpdates(UpdateAvailableStatus status);
+
+ void konamiTriggered();
+
+ void globalSettingsClosed();
+
+ private:
+ void retranslateUi();
+
+ void addInstance(QString url = QString());
+ void activateInstance(InstancePtr instance);
+ void setCatBackground(bool enabled);
+ void updateInstanceToolIcon(QString new_icon);
+ void setSelectedInstanceById(const QString& id);
+ void updateStatusCenter();
+
+ void runModalTask(Task* task);
+ void instanceFromInstanceTask(InstanceTask* task);
+ void finalizeInstance(InstancePtr inst);
+
+ private:
+ std::unique_ptr<Ui> ui;
+
+ // these are managed by Qt's memory management model!
+ InstanceView* view = nullptr;
+ InstanceProxyModel* proxymodel = nullptr;
+ QToolButton* newsLabel = nullptr;
+ QLabel* m_statusLeft = nullptr;
+ QLabel* m_statusCenter = nullptr;
+ QMenu* accountMenu = nullptr;
+ QToolButton* accountMenuButton = nullptr;
+ KonamiCode* secretEventFilter = nullptr;
+
+ unique_qobject_ptr<NewsChecker> m_newsChecker;
+ unique_qobject_ptr<NotificationChecker> m_notificationChecker;
+
+ InstancePtr m_selectedInstance;
+ QString m_currentInstIcon;
+
+ // managed by the application object
+ Task* m_versionLoadTask = nullptr;
+};
diff --git a/meshmc/launcher/ui/dialogs/AboutDialog.cpp b/meshmc/launcher/ui/dialogs/AboutDialog.cpp
new file mode 100644
index 0000000000..45fdcf6bc5
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/AboutDialog.cpp
@@ -0,0 +1,146 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "ui_AboutDialog.h"
+#include <QIcon>
+#include "Application.h"
+#include "BuildConfig.h"
+
+#include <net/NetJob.h>
+
+#include "HoeDown.h"
+#include "MMCStrings.h"
+
+namespace
+{
+ // Credits
+ QString getCreditsHtml()
+ {
+ QFile dataFile(":/documents/credits.html");
+ if (!dataFile.open(QIODevice::ReadOnly)) {
+ qWarning() << "Failed to open file" << dataFile.fileName()
+ << "for reading:" << dataFile.errorString();
+ return {};
+ }
+ QString fileContent = QString::fromUtf8(dataFile.readAll());
+ dataFile.close();
+
+ return fileContent.arg(
+ QObject::tr("%1 Developers").arg(BuildConfig.MESHMC_DISPLAYNAME),
+ QObject::tr("MultiMC Developers"));
+ }
+
+ QString getLicenseHtml()
+ {
+ QFile dataFile(":/documents/COPYING.md");
+ if (dataFile.open(QIODevice::ReadOnly)) {
+ HoeDown hoedown;
+ QString output = hoedown.process(dataFile.readAll());
+ dataFile.close();
+ return output;
+ } else {
+ qWarning() << "Failed to open file" << dataFile.fileName()
+ << "for reading:" << dataFile.errorString();
+ return QString();
+ }
+ }
+
+} // namespace
+
+AboutDialog::AboutDialog(QWidget* parent)
+ : QDialog(parent), ui(new Ui::AboutDialog)
+{
+ ui->setupUi(this);
+
+ QString launcherName = BuildConfig.MESHMC_DISPLAYNAME;
+
+ setWindowTitle(tr("About %1").arg(launcherName));
+
+ QString chtml = getCreditsHtml();
+ ui->creditsText->setHtml(Strings::htmlListPatch(chtml));
+
+ QString lhtml = getLicenseHtml();
+ ui->licenseText->setHtml(Strings::htmlListPatch(lhtml));
+
+ ui->urlLabel->setOpenExternalLinks(true);
+
+ ui->icon->setPixmap(APPLICATION->getThemedIcon("logo").pixmap(64));
+ ui->title->setText(launcherName);
+
+ 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.GIT_COMMIT.isEmpty())
+ ui->commitLabel->setText(tr("Commit: %1").arg(BuildConfig.GIT_COMMIT));
+ else
+ ui->commitLabel->setVisible(false);
+
+ if (!BuildConfig.BUILD_DATE.isEmpty())
+ ui->buildDateLabel->setText(
+ tr("Build date: %1").arg(BuildConfig.BUILD_DATE));
+ else
+ ui->buildDateLabel->setVisible(false);
+
+ if (!BuildConfig.VERSION_CHANNEL.isEmpty())
+ ui->channelLabel->setText(tr("Channel") + ": " +
+ BuildConfig.VERSION_CHANNEL);
+ else
+ ui->channelLabel->setVisible(false);
+
+ QString urlText(
+ "<html><head/><body><p><a href=\"%1\">%1</a></p></body></html>");
+ ui->urlLabel->setText(urlText.arg(BuildConfig.MESHMC_GIT));
+
+ QString copyText("© 2026 %1");
+ ui->copyLabel->setText(copyText.arg(BuildConfig.MESHMC_COPYRIGHT));
+
+ connect(ui->closeButton, &QPushButton::clicked, this, &AboutDialog::close);
+
+ connect(ui->aboutQt, &QPushButton::clicked, &QApplication::aboutQt);
+}
+
+AboutDialog::~AboutDialog()
+{
+ delete ui;
+}
diff --git a/meshmc/launcher/ui/dialogs/AboutDialog.h b/meshmc/launcher/ui/dialogs/AboutDialog.h
new file mode 100644
index 0000000000..c4a4dcaa7b
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/AboutDialog.h
@@ -0,0 +1,62 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <net/NetJob.h>
+
+namespace Ui
+{
+ class AboutDialog;
+}
+
+class AboutDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit AboutDialog(QWidget* parent = 0);
+ ~AboutDialog();
+
+ private:
+ Ui::AboutDialog* ui;
+
+ NetJob::Ptr netJob;
+ QByteArray dataSink;
+};
diff --git a/meshmc/launcher/ui/dialogs/AboutDialog.ui b/meshmc/launcher/ui/dialogs/AboutDialog.ui
new file mode 100644
index 0000000000..b4eb31e982
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/AboutDialog.ui
@@ -0,0 +1,335 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>AboutDialog</class>
+ <widget class="QDialog" name="AboutDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>573</width>
+ <height>600</height>
+ </rect>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>450</width>
+ <height>400</height>
+ </size>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <item>
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QLabel" name="icon">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>64</width>
+ <height>64</height>
+ </size>
+ </property>
+ <property name="baseSize">
+ <size>
+ <width>64</width>
+ <height>64</height>
+ </size>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer_2">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QLabel" name="title">
+ <property name="font">
+ <font>
+ <pointsize>15</pointsize>
+ </font>
+ </property>
+ <property name="text">
+ <string notr="true">MeshMC</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item alignment="Qt::AlignHCenter">
+ <widget class="QLabel" name="versionLabel">
+ <property name="cursor">
+ <cursorShape>IBeamCursor</cursorShape>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="aboutTab">
+ <attribute name="title">
+ <string>About</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_5">
+ <item>
+ <widget class="QLabel" name="aboutLabel">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="text">
+ <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;A custom launcher that makes managing Minecraft easier by allowing you to have multiple instances of Minecraft at once.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="urlLabel">
+ <property name="font">
+ <font>
+ <pointsize>10</pointsize>
+ </font>
+ </property>
+ <property name="text">
+ <string notr="true">GIT URL</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="copyLabel">
+ <property name="font">
+ <font>
+ <pointsize>8</pointsize>
+ <kerning>true</kerning>
+ </font>
+ </property>
+ <property name="text">
+ <string notr="true">COPYRIGHT</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="Line" name="line">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ </widget>
+ </item>
+ <item alignment="Qt::AlignHCenter">
+ <widget class="QLabel" name="platformLabel">
+ <property name="cursor">
+ <cursorShape>IBeamCursor</cursorShape>
+ </property>
+ <property name="text">
+ <string>Platform:</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item alignment="Qt::AlignHCenter">
+ <widget class="QLabel" name="buildDateLabel">
+ <property name="cursor">
+ <cursorShape>IBeamCursor</cursorShape>
+ </property>
+ <property name="text">
+ <string>Build Date:</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item alignment="Qt::AlignHCenter">
+ <widget class="QLabel" name="commitLabel">
+ <property name="cursor">
+ <cursorShape>IBeamCursor</cursorShape>
+ </property>
+ <property name="text">
+ <string>Commit:</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item alignment="Qt::AlignHCenter">
+ <widget class="QLabel" name="channelLabel">
+ <property name="cursor">
+ <cursorShape>IBeamCursor</cursorShape>
+ </property>
+ <property name="text">
+ <string>Channel:</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>212</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="creditsTab">
+ <attribute name="title">
+ <string>Credits</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <item>
+ <widget class="QTextBrowser" name="creditsText">
+ <property name="openExternalLinks">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="licenseTab">
+ <attribute name="title">
+ <string>License</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_3">
+ <item>
+ <widget class="QTextEdit" name="licenseText">
+ <property name="minimumSize">
+ <size>
+ <width>0</width>
+ <height>0</height>
+ </size>
+ </property>
+ <property name="font">
+ <font>
+ <family>DejaVu Sans Mono</family>
+ </font>
+ </property>
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::TextBrowserInteraction</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_2">
+ <item>
+ <widget class="QPushButton" name="aboutQt">
+ <property name="autoFillBackground">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string>About Qt</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer_3">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QPushButton" name="closeButton">
+ <property name="text">
+ <string>Close</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>tabWidget</tabstop>
+ <tabstop>creditsText</tabstop>
+ <tabstop>licenseText</tabstop>
+ <tabstop>aboutQt</tabstop>
+ <tabstop>closeButton</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/BlockedModsDialog.cpp b/meshmc/launcher/ui/dialogs/BlockedModsDialog.cpp
new file mode 100644
index 0000000000..884c1d186d
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/BlockedModsDialog.cpp
@@ -0,0 +1,178 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "BlockedModsDialog.h"
+
+#include <QDesktopServices>
+#include <QDir>
+#include <QFont>
+#include <QGridLayout>
+#include <QScrollArea>
+#include <QStandardPaths>
+#include <QUrl>
+
+BlockedModsDialog::BlockedModsDialog(QWidget* parent, const QString& title,
+ const QString& text,
+ QList<BlockedMod>& mods)
+ : QDialog(parent), m_mods(mods)
+{
+ setWindowTitle(title);
+ setMinimumSize(550, 300);
+ resize(620, 420);
+ setWindowModality(Qt::WindowModal);
+
+ auto* mainLayout = new QVBoxLayout(this);
+
+ // Description label at top
+ auto* descLabel = new QLabel(text, this);
+ descLabel->setWordWrap(true);
+ mainLayout->addWidget(descLabel);
+
+ // Scrollable area for mod list
+ auto* scrollArea = new QScrollArea(this);
+ scrollArea->setWidgetResizable(true);
+
+ auto* scrollWidget = new QWidget();
+ auto* grid = new QGridLayout(scrollWidget);
+ grid->setColumnStretch(0, 3); // mod name
+ grid->setColumnStretch(1, 1); // status
+ grid->setColumnStretch(2, 0); // button
+
+ // Header row
+ auto* headerName = new QLabel(tr("<b>Mod</b>"), scrollWidget);
+ auto* headerStatus = new QLabel(tr("<b>Status</b>"), scrollWidget);
+ grid->addWidget(headerName, 0, 0);
+ grid->addWidget(headerStatus, 0, 1);
+
+ for (int i = 0; i < m_mods.size(); i++) {
+ int row = i + 1;
+
+ auto* nameLabel = new QLabel(m_mods[i].fileName, scrollWidget);
+ nameLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
+
+ auto* statusLabel = new QLabel(tr("Missing"), scrollWidget);
+ statusLabel->setStyleSheet("color: #cc3333; font-weight: bold;");
+
+ auto* downloadBtn = new QPushButton(tr("Download"), scrollWidget);
+ connect(downloadBtn, &QPushButton::clicked, this,
+ [this, i]() { openModDownload(i); });
+
+ grid->addWidget(nameLabel, row, 0);
+ grid->addWidget(statusLabel, row, 1);
+ grid->addWidget(downloadBtn, row, 2);
+
+ m_rows.append({nameLabel, statusLabel, downloadBtn});
+ }
+
+ // Add stretch at bottom of grid
+ grid->setRowStretch(m_mods.size() + 1, 1);
+
+ scrollWidget->setLayout(grid);
+ scrollArea->setWidget(scrollWidget);
+ mainLayout->addWidget(scrollArea, 1);
+
+ // Button box at bottom
+ m_buttons = new QDialogButtonBox(
+ QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
+ m_buttons->button(QDialogButtonBox::Ok)->setText(tr("Continue"));
+ m_buttons->button(QDialogButtonBox::Ok)->setEnabled(false);
+ connect(m_buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
+ connect(m_buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
+ mainLayout->addWidget(m_buttons);
+
+ setLayout(mainLayout);
+
+ // Set up Downloads folder watching
+ setupWatch();
+
+ // Initial scan
+ scanDownloadsFolder();
+}
+
+void BlockedModsDialog::setupWatch()
+{
+ m_downloadDir =
+ QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
+ if (!m_downloadDir.isEmpty() && QDir(m_downloadDir).exists()) {
+ m_watcher.addPath(m_downloadDir);
+ connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this,
+ &BlockedModsDialog::onDownloadDirChanged);
+ }
+}
+
+void BlockedModsDialog::onDownloadDirChanged(const QString& path)
+{
+ Q_UNUSED(path);
+ scanDownloadsFolder();
+}
+
+void BlockedModsDialog::scanDownloadsFolder()
+{
+ if (m_downloadDir.isEmpty())
+ return;
+
+ QDir dir(m_downloadDir);
+ QStringList files = dir.entryList(QDir::Files);
+
+ for (int i = 0; i < m_mods.size(); i++) {
+ if (!m_mods[i].found && files.contains(m_mods[i].fileName)) {
+ m_mods[i].found = true;
+ }
+ }
+
+ updateModStatus();
+}
+
+void BlockedModsDialog::updateModStatus()
+{
+ bool allFound = true;
+
+ for (int i = 0; i < m_mods.size(); i++) {
+ if (m_mods[i].found) {
+ m_rows[i].statusLabel->setText(QString::fromUtf8("\u2714 ") +
+ tr("Found"));
+ m_rows[i].statusLabel->setStyleSheet(
+ "color: #33aa33; font-weight: bold;");
+ m_rows[i].downloadButton->setEnabled(false);
+ } else {
+ m_rows[i].statusLabel->setText(tr("Missing"));
+ m_rows[i].statusLabel->setStyleSheet(
+ "color: #cc3333; font-weight: bold;");
+ m_rows[i].downloadButton->setEnabled(true);
+ allFound = false;
+ }
+ }
+
+ m_buttons->button(QDialogButtonBox::Ok)->setEnabled(allFound);
+}
+
+void BlockedModsDialog::openModDownload(int index)
+{
+ if (index < 0 || index >= m_mods.size())
+ return;
+
+ const auto& mod = m_mods[index];
+ QString url =
+ QString("https://www.curseforge.com/api/v1/mods/%1/files/%2/download")
+ .arg(mod.projectId)
+ .arg(mod.fileId);
+ QDesktopServices::openUrl(QUrl(url));
+}
diff --git a/meshmc/launcher/ui/dialogs/BlockedModsDialog.h b/meshmc/launcher/ui/dialogs/BlockedModsDialog.h
new file mode 100644
index 0000000000..77db2fc11b
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/BlockedModsDialog.h
@@ -0,0 +1,74 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <QFileSystemWatcher>
+#include <QLabel>
+#include <QPushButton>
+#include <QDialogButtonBox>
+#include <QVBoxLayout>
+
+struct BlockedMod {
+ int projectId;
+ int fileId;
+ QString fileName;
+ QString targetPath;
+ bool found = false;
+};
+
+class BlockedModsDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit BlockedModsDialog(QWidget* parent, const QString& title,
+ const QString& text, QList<BlockedMod>& mods);
+
+ /// Returns the list of mods with updated `found` status
+ QList<BlockedMod>& resultMods()
+ {
+ return m_mods;
+ }
+
+ private slots:
+ void onDownloadDirChanged(const QString& path);
+ void openModDownload(int index);
+
+ private:
+ void scanDownloadsFolder();
+ void updateModStatus();
+ void setupWatch();
+
+ QList<BlockedMod>& m_mods;
+ QString m_downloadDir;
+ QFileSystemWatcher m_watcher;
+
+ struct ModRow {
+ QLabel* nameLabel;
+ QLabel* statusLabel;
+ QPushButton* downloadButton;
+ };
+ QList<ModRow> m_rows;
+
+ QDialogButtonBox* m_buttons;
+};
diff --git a/meshmc/launcher/ui/dialogs/CopyInstanceDialog.cpp b/meshmc/launcher/ui/dialogs/CopyInstanceDialog.cpp
new file mode 100644
index 0000000000..926f0b00a1
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/CopyInstanceDialog.cpp
@@ -0,0 +1,157 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 <QLayout>
+#include <QPushButton>
+
+#include "Application.h"
+#include "CopyInstanceDialog.h"
+#include "ui_CopyInstanceDialog.h"
+
+#include "ui/dialogs/IconPickerDialog.h"
+
+#include "BaseVersion.h"
+#include "icons/IconList.h"
+#include "tasks/Task.h"
+#include "BaseInstance.h"
+#include "InstanceList.h"
+
+CopyInstanceDialog::CopyInstanceDialog(InstancePtr original, QWidget* parent)
+ : QDialog(parent), ui(new Ui::CopyInstanceDialog), m_original(original)
+{
+ ui->setupUi(this);
+ resize(minimumSizeHint());
+ layout()->setSizeConstraint(QLayout::SetFixedSize);
+
+ InstIconKey = original->iconKey();
+ ui->iconButton->setIcon(APPLICATION->icons()->getIcon(InstIconKey));
+ ui->instNameTextBox->setText(original->name());
+ ui->instNameTextBox->setFocus();
+ auto groupList = APPLICATION->instances()->getGroups();
+ groupList.removeDuplicates();
+ groupList.sort(Qt::CaseInsensitive);
+ groupList.removeOne("");
+ groupList.push_front("");
+ ui->groupBox->addItems(groupList);
+ int index = groupList.indexOf(
+ APPLICATION->instances()->getInstanceGroup(m_original->id()));
+ if (index == -1) {
+ index = 0;
+ }
+ ui->groupBox->setCurrentIndex(index);
+ ui->groupBox->lineEdit()->setPlaceholderText(tr("No group"));
+ ui->copySavesCheckbox->setChecked(m_copySaves);
+ ui->keepPlaytimeCheckbox->setChecked(m_keepPlaytime);
+}
+
+CopyInstanceDialog::~CopyInstanceDialog()
+{
+ delete ui;
+}
+
+void CopyInstanceDialog::updateDialogState()
+{
+ auto allowOK = !instName().isEmpty();
+ auto OkButton = ui->buttonBox->button(QDialogButtonBox::Ok);
+ if (OkButton->isEnabled() != allowOK) {
+ OkButton->setEnabled(allowOK);
+ }
+}
+
+QString CopyInstanceDialog::instName() const
+{
+ auto result = ui->instNameTextBox->text().trimmed();
+ if (result.size()) {
+ return result;
+ }
+ return QString();
+}
+
+QString CopyInstanceDialog::iconKey() const
+{
+ return InstIconKey;
+}
+
+QString CopyInstanceDialog::instGroup() const
+{
+ return ui->groupBox->currentText();
+}
+
+void CopyInstanceDialog::on_iconButton_clicked()
+{
+ IconPickerDialog dlg(this);
+ dlg.execWithSelection(InstIconKey);
+
+ if (dlg.result() == QDialog::Accepted) {
+ InstIconKey = dlg.selectedIconKey;
+ ui->iconButton->setIcon(APPLICATION->icons()->getIcon(InstIconKey));
+ }
+}
+
+void CopyInstanceDialog::on_instNameTextBox_textChanged(const QString& arg1)
+{
+ updateDialogState();
+}
+
+bool CopyInstanceDialog::shouldCopySaves() const
+{
+ return m_copySaves;
+}
+
+void CopyInstanceDialog::on_copySavesCheckbox_stateChanged(int state)
+{
+ if (state == Qt::Unchecked) {
+ m_copySaves = false;
+ } else if (state == Qt::Checked) {
+ m_copySaves = true;
+ }
+}
+
+bool CopyInstanceDialog::shouldKeepPlaytime() const
+{
+ return m_keepPlaytime;
+}
+
+void CopyInstanceDialog::on_keepPlaytimeCheckbox_stateChanged(int state)
+{
+ if (state == Qt::Unchecked) {
+ m_keepPlaytime = false;
+ } else if (state == Qt::Checked) {
+ m_keepPlaytime = true;
+ }
+}
diff --git a/meshmc/launcher/ui/dialogs/CopyInstanceDialog.h b/meshmc/launcher/ui/dialogs/CopyInstanceDialog.h
new file mode 100644
index 0000000000..adbbbd36fb
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/CopyInstanceDialog.h
@@ -0,0 +1,80 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include "BaseVersion.h"
+#include <BaseInstance.h>
+
+class BaseInstance;
+
+namespace Ui
+{
+ class CopyInstanceDialog;
+}
+
+class CopyInstanceDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit CopyInstanceDialog(InstancePtr original, QWidget* parent = 0);
+ ~CopyInstanceDialog();
+
+ void updateDialogState();
+
+ QString instName() const;
+ QString instGroup() const;
+ QString iconKey() const;
+ bool shouldCopySaves() const;
+ bool shouldKeepPlaytime() const;
+
+ private slots:
+ void on_iconButton_clicked();
+ void on_instNameTextBox_textChanged(const QString& arg1);
+ void on_copySavesCheckbox_stateChanged(int state);
+ void on_keepPlaytimeCheckbox_stateChanged(int state);
+
+ private:
+ Ui::CopyInstanceDialog* ui;
+ QString InstIconKey;
+ InstancePtr m_original;
+ bool m_copySaves = true;
+ bool m_keepPlaytime = true;
+};
diff --git a/meshmc/launcher/ui/dialogs/CopyInstanceDialog.ui b/meshmc/launcher/ui/dialogs/CopyInstanceDialog.ui
new file mode 100644
index 0000000000..f4b191e272
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/CopyInstanceDialog.ui
@@ -0,0 +1,182 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>CopyInstanceDialog</class>
+ <widget class="QDialog" name="CopyInstanceDialog">
+ <property name="windowModality">
+ <enum>Qt::ApplicationModal</enum>
+ </property>
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>345</width>
+ <height>323</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Copy Instance</string>
+ </property>
+ <property name="windowIcon">
+ <iconset>
+ <normaloff>:/icons/toolbar/copy</normaloff>:/icons/toolbar/copy</iconset>
+ </property>
+ <property name="modal">
+ <bool>true</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <layout class="QHBoxLayout" name="iconBtnLayout">
+ <item>
+ <spacer name="iconBtnLeftSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QToolButton" name="iconButton">
+ <property name="icon">
+ <iconset>
+ <normaloff>:/icons/instances/grass</normaloff>:/icons/instances/grass</iconset>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>80</width>
+ <height>80</height>
+ </size>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="iconBtnRightSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QLineEdit" name="instNameTextBox">
+ <property name="placeholderText">
+ <string>Name</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="Line" name="line">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="0" column="0">
+ <widget class="QLabel" name="labelVersion_3">
+ <property name="text">
+ <string>&amp;Group</string>
+ </property>
+ <property name="buddy">
+ <cstring>groupBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1" colspan="2">
+ <widget class="QComboBox" name="groupBox">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="editable">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="copySavesCheckbox">
+ <property name="text">
+ <string>Copy saves</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="keepPlaytimeCheckbox">
+ <property name="text">
+ <string>Keep play time</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QDialogButtonBox" name="buttonBox">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="standardButtons">
+ <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>iconButton</tabstop>
+ <tabstop>instNameTextBox</tabstop>
+ <tabstop>groupBox</tabstop>
+ <tabstop>copySavesCheckbox</tabstop>
+ <tabstop>keepPlaytimeCheckbox</tabstop>
+ </tabstops>
+ <resources>
+ <include location="../../graphics.qrc"/>
+ </resources>
+ <connections>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>accepted()</signal>
+ <receiver>CopyInstanceDialog</receiver>
+ <slot>accept()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>248</x>
+ <y>254</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>157</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>rejected()</signal>
+ <receiver>CopyInstanceDialog</receiver>
+ <slot>reject()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>316</x>
+ <y>260</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>286</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ </connections>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/CustomMessageBox.cpp b/meshmc/launcher/ui/dialogs/CustomMessageBox.cpp
new file mode 100644
index 0000000000..80a87d8d0b
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/CustomMessageBox.cpp
@@ -0,0 +1,59 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "CustomMessageBox.h"
+
+namespace CustomMessageBox
+{
+ QMessageBox* selectable(QWidget* parent, const QString& title,
+ const QString& text, QMessageBox::Icon icon,
+ QMessageBox::StandardButtons buttons,
+ QMessageBox::StandardButton defaultButton)
+ {
+ QMessageBox* messageBox = new QMessageBox(parent);
+ messageBox->setWindowTitle(title);
+ messageBox->setText(text);
+ messageBox->setStandardButtons(buttons);
+ messageBox->setDefaultButton(defaultButton);
+ messageBox->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ messageBox->setIcon(icon);
+ messageBox->setTextInteractionFlags(Qt::TextBrowserInteraction);
+
+ return messageBox;
+ }
+} // namespace CustomMessageBox
diff --git a/meshmc/launcher/ui/dialogs/CustomMessageBox.h b/meshmc/launcher/ui/dialogs/CustomMessageBox.h
new file mode 100644
index 0000000000..18656c71db
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/CustomMessageBox.h
@@ -0,0 +1,50 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QMessageBox>
+
+namespace CustomMessageBox
+{
+ QMessageBox* selectable(
+ QWidget* parent, const QString& title, const QString& text,
+ QMessageBox::Icon icon = QMessageBox::NoIcon,
+ QMessageBox::StandardButtons buttons = QMessageBox::Ok,
+ QMessageBox::StandardButton defaultButton = QMessageBox::NoButton);
+}
diff --git a/meshmc/launcher/ui/dialogs/EditAccountDialog.cpp b/meshmc/launcher/ui/dialogs/EditAccountDialog.cpp
new file mode 100644
index 0000000000..30e9f142f9
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/EditAccountDialog.cpp
@@ -0,0 +1,85 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "EditAccountDialog.h"
+#include "ui_EditAccountDialog.h"
+#include <DesktopServices.h>
+#include <QUrl>
+
+EditAccountDialog::EditAccountDialog(const QString& text, QWidget* parent,
+ int flags)
+ : QDialog(parent), ui(new Ui::EditAccountDialog)
+{
+ ui->setupUi(this);
+
+ ui->label->setText(text);
+ ui->label->setVisible(!text.isEmpty());
+
+ ui->userTextBox->setEnabled(flags & UsernameField);
+ ui->passTextBox->setEnabled(flags & PasswordField);
+}
+
+EditAccountDialog::~EditAccountDialog()
+{
+ delete ui;
+}
+
+void EditAccountDialog::on_label_linkActivated(const QString& link)
+{
+ DesktopServices::openUrl(QUrl(link));
+}
+
+void EditAccountDialog::setUsername(const QString& user) const
+{
+ ui->userTextBox->setText(user);
+}
+
+QString EditAccountDialog::username() const
+{
+ return ui->userTextBox->text();
+}
+
+void EditAccountDialog::setPassword(const QString& pass) const
+{
+ ui->passTextBox->setText(pass);
+}
+
+QString EditAccountDialog::password() const
+{
+ return ui->passTextBox->text();
+}
diff --git a/meshmc/launcher/ui/dialogs/EditAccountDialog.h b/meshmc/launcher/ui/dialogs/EditAccountDialog.h
new file mode 100644
index 0000000000..ac1af0efbf
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/EditAccountDialog.h
@@ -0,0 +1,78 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+
+namespace Ui
+{
+ class EditAccountDialog;
+}
+
+class EditAccountDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit EditAccountDialog(const QString& text = "", QWidget* parent = 0,
+ int flags = UsernameField | PasswordField);
+ ~EditAccountDialog();
+
+ void setUsername(const QString& user) const;
+ void setPassword(const QString& pass) const;
+
+ QString username() const;
+ QString password() const;
+
+ enum Flags {
+ NoFlags = 0,
+
+ //! Specifies that the dialog should have a username field.
+ UsernameField,
+
+ //! Specifies that the dialog should have a password field.
+ PasswordField,
+ };
+
+ private slots:
+ void on_label_linkActivated(const QString& link);
+
+ private:
+ Ui::EditAccountDialog* ui;
+};
diff --git a/meshmc/launcher/ui/dialogs/EditAccountDialog.ui b/meshmc/launcher/ui/dialogs/EditAccountDialog.ui
new file mode 100644
index 0000000000..e87509bcbc
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/EditAccountDialog.ui
@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>EditAccountDialog</class>
+ <widget class="QDialog" name="EditAccountDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>400</width>
+ <height>148</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Login</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string notr="true">Message label placeholder.</string>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::RichText</enum>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLineEdit" name="userTextBox">
+ <property name="placeholderText">
+ <string>Email</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLineEdit" name="passTextBox">
+ <property name="echoMode">
+ <enum>QLineEdit::Password</enum>
+ </property>
+ <property name="placeholderText">
+ <string>Password</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QDialogButtonBox" name="buttonBox">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="standardButtons">
+ <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>accepted()</signal>
+ <receiver>EditAccountDialog</receiver>
+ <slot>accept()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>248</x>
+ <y>254</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>157</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>rejected()</signal>
+ <receiver>EditAccountDialog</receiver>
+ <slot>reject()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>316</x>
+ <y>260</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>286</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ </connections>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/ExportInstanceDialog.cpp b/meshmc/launcher/ui/dialogs/ExportInstanceDialog.cpp
new file mode 100644
index 0000000000..059c994dfd
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/ExportInstanceDialog.cpp
@@ -0,0 +1,462 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "ExportInstanceDialog.h"
+#include "ui_ExportInstanceDialog.h"
+#include <BaseInstance.h>
+#include <MMCZip.h>
+#include <QFileDialog>
+#include <QMessageBox>
+#include <qfilesystemmodel.h>
+
+#include <QSortFilterProxyModel>
+#include <QDebug>
+#include <qstack.h>
+#include <QSaveFile>
+#include "MMCStrings.h"
+#include "SeparatorPrefixTree.h"
+#include "Application.h"
+#include <icons/IconList.h>
+#include <FileSystem.h>
+
+class PackIgnoreProxy : public QSortFilterProxyModel
+{
+ Q_OBJECT
+
+ public:
+ PackIgnoreProxy(InstancePtr instance, QObject* parent)
+ : QSortFilterProxyModel(parent)
+ {
+ m_instance = instance;
+ }
+ // NOTE: Sadly, we have to do sorting ourselves.
+ bool lessThan(const QModelIndex& left, const QModelIndex& right) const
+ {
+ QFileSystemModel* fsm = qobject_cast<QFileSystemModel*>(sourceModel());
+ if (!fsm) {
+ return QSortFilterProxyModel::lessThan(left, right);
+ }
+ bool asc = sortOrder() == Qt::AscendingOrder ? true : false;
+
+ QFileInfo leftFileInfo = fsm->fileInfo(left);
+ QFileInfo rightFileInfo = fsm->fileInfo(right);
+
+ if (!leftFileInfo.isDir() && rightFileInfo.isDir()) {
+ return !asc;
+ }
+ if (leftFileInfo.isDir() && !rightFileInfo.isDir()) {
+ return asc;
+ }
+
+ // sort and proxy model breaks the original model...
+ if (sortColumn() == 0) {
+ return Strings::naturalCompare(leftFileInfo.fileName(),
+ rightFileInfo.fileName(),
+ Qt::CaseInsensitive) < 0;
+ }
+ if (sortColumn() == 1) {
+ auto leftSize = leftFileInfo.size();
+ auto rightSize = rightFileInfo.size();
+ if ((leftSize == rightSize) ||
+ (leftFileInfo.isDir() && rightFileInfo.isDir())) {
+ return Strings::naturalCompare(leftFileInfo.fileName(),
+ rightFileInfo.fileName(),
+ Qt::CaseInsensitive) < 0
+ ? asc
+ : !asc;
+ }
+ return leftSize < rightSize;
+ }
+ return QSortFilterProxyModel::lessThan(left, right);
+ }
+
+ virtual Qt::ItemFlags flags(const QModelIndex& index) const
+ {
+ if (!index.isValid())
+ return Qt::NoItemFlags;
+
+ auto sourceIndex = mapToSource(index);
+ Qt::ItemFlags flags = sourceIndex.flags();
+ if (index.column() == 0) {
+ flags |= Qt::ItemIsUserCheckable;
+ if (sourceIndex.model()->hasChildren(sourceIndex)) {
+ flags |= Qt::ItemIsAutoTristate;
+ }
+ }
+
+ return flags;
+ }
+
+ virtual QVariant data(const QModelIndex& index,
+ int role = Qt::DisplayRole) const
+ {
+ QModelIndex sourceIndex = mapToSource(index);
+
+ if (index.column() == 0 && role == Qt::CheckStateRole) {
+ QFileSystemModel* fsm =
+ qobject_cast<QFileSystemModel*>(sourceModel());
+ auto blockedPath = relPath(fsm->filePath(sourceIndex));
+ auto cover = blocked.cover(blockedPath);
+ if (!cover.isNull()) {
+ return QVariant(Qt::Unchecked);
+ } else if (blocked.exists(blockedPath)) {
+ return QVariant(Qt::PartiallyChecked);
+ } else {
+ return QVariant(Qt::Checked);
+ }
+ }
+
+ return sourceIndex.data(role);
+ }
+
+ virtual bool setData(const QModelIndex& index, const QVariant& value,
+ int role = Qt::EditRole)
+ {
+ if (index.column() == 0 && role == Qt::CheckStateRole) {
+ Qt::CheckState state = static_cast<Qt::CheckState>(value.toInt());
+ return setFilterState(index, state);
+ }
+
+ QModelIndex sourceIndex = mapToSource(index);
+ return QSortFilterProxyModel::sourceModel()->setData(sourceIndex, value,
+ role);
+ }
+
+ QString relPath(const QString& path) const
+ {
+ QString prefix = QDir().absoluteFilePath(m_instance->instanceRoot());
+ prefix += '/';
+ if (!path.startsWith(prefix)) {
+ return QString();
+ }
+ return path.mid(prefix.size());
+ }
+
+ bool setFilterState(QModelIndex index, Qt::CheckState state)
+ {
+ QFileSystemModel* fsm = qobject_cast<QFileSystemModel*>(sourceModel());
+
+ if (!fsm) {
+ return false;
+ }
+
+ QModelIndex sourceIndex = mapToSource(index);
+ auto blockedPath = relPath(fsm->filePath(sourceIndex));
+ bool changed = false;
+ if (state == Qt::Unchecked) {
+ // blocking a path
+ auto& node = blocked.insert(blockedPath);
+ // get rid of all blocked nodes below
+ node.clear();
+ changed = true;
+ } else if (state == Qt::Checked || state == Qt::PartiallyChecked) {
+ if (!blocked.remove(blockedPath)) {
+ auto cover = blocked.cover(blockedPath);
+ qDebug() << "Blocked by cover" << cover;
+ // uncover
+ blocked.remove(cover);
+ // block all contents, except for any cover
+ QModelIndex rootIndex = fsm->index(
+ FS::PathCombine(m_instance->instanceRoot(), cover));
+ QModelIndex doing = rootIndex;
+ int row = 0;
+ QStack<QModelIndex> todo;
+ while (1) {
+ auto node = fsm->index(row, 0, doing);
+ if (!node.isValid()) {
+ if (!todo.size()) {
+ break;
+ } else {
+ doing = todo.pop();
+ row = 0;
+ continue;
+ }
+ }
+ auto relpath = relPath(fsm->filePath(node));
+ if (blockedPath.startsWith(relpath)) // cover found?
+ {
+ // continue processing cover later
+ todo.push(node);
+ } else {
+ // or just block this one.
+ blocked.insert(relpath);
+ }
+ row++;
+ }
+ }
+ changed = true;
+ }
+ if (changed) {
+ // update the thing
+ emit dataChanged(index, index, {Qt::CheckStateRole});
+ // update everything above index
+ QModelIndex up = index.parent();
+ while (1) {
+ if (!up.isValid())
+ break;
+ emit dataChanged(up, up, {Qt::CheckStateRole});
+ up = up.parent();
+ }
+ // and everything below the index
+ QModelIndex doing = index;
+ int row = 0;
+ QStack<QModelIndex> todo;
+ while (1) {
+ auto node = doing.model()->index(row, 0, doing);
+ if (!node.isValid()) {
+ if (!todo.size()) {
+ break;
+ } else {
+ doing = todo.pop();
+ row = 0;
+ continue;
+ }
+ }
+ emit dataChanged(node, node, {Qt::CheckStateRole});
+ todo.push(node);
+ row++;
+ }
+ // siblings and unrelated nodes are ignored
+ }
+ return true;
+ }
+
+ bool shouldExpand(QModelIndex index)
+ {
+ QModelIndex sourceIndex = mapToSource(index);
+ QFileSystemModel* fsm = qobject_cast<QFileSystemModel*>(sourceModel());
+ if (!fsm) {
+ return false;
+ }
+ auto blockedPath = relPath(fsm->filePath(sourceIndex));
+ auto found = blocked.find(blockedPath);
+ if (found) {
+ return !found->leaf();
+ }
+ return false;
+ }
+
+ void setBlockedPaths(QStringList paths)
+ {
+ beginResetModel();
+ blocked.clear();
+ blocked.insert(paths);
+ endResetModel();
+ }
+
+ const SeparatorPrefixTree<'/'>& blockedPaths() const
+ {
+ return blocked;
+ }
+
+ protected:
+ bool filterAcceptsColumn(int source_column,
+ const QModelIndex& source_parent) const
+ {
+ Q_UNUSED(source_parent)
+
+ // adjust the columns you want to filter out here
+ // return false for those that will be hidden
+ if (source_column == 2 || source_column == 3)
+ return false;
+
+ return true;
+ }
+
+ private:
+ InstancePtr m_instance;
+ SeparatorPrefixTree<'/'> blocked;
+};
+
+ExportInstanceDialog::ExportInstanceDialog(InstancePtr instance,
+ QWidget* parent)
+ : QDialog(parent), ui(new Ui::ExportInstanceDialog), m_instance(instance)
+{
+ ui->setupUi(this);
+ auto model = new QFileSystemModel(this);
+ proxyModel = new PackIgnoreProxy(m_instance, this);
+ loadPackIgnore();
+ proxyModel->setSourceModel(model);
+ auto root = instance->instanceRoot();
+ ui->treeView->setModel(proxyModel);
+ ui->treeView->setRootIndex(proxyModel->mapFromSource(model->index(root)));
+ ui->treeView->sortByColumn(0, Qt::AscendingOrder);
+
+ connect(proxyModel, SIGNAL(rowsInserted(QModelIndex, int, int)),
+ SLOT(rowsInserted(QModelIndex, int, int)));
+
+ model->setFilter(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::AllDirs |
+ QDir::Hidden);
+ model->setRootPath(root);
+ auto headerView = ui->treeView->header();
+ headerView->setSectionResizeMode(QHeaderView::ResizeToContents);
+ headerView->setSectionResizeMode(0, QHeaderView::Stretch);
+}
+
+ExportInstanceDialog::~ExportInstanceDialog()
+{
+ delete ui;
+}
+
+/// Save icon to instance's folder is needed
+void SaveIcon(InstancePtr m_instance)
+{
+ auto iconKey = m_instance->iconKey();
+ auto iconList = APPLICATION->icons();
+ auto mmcIcon = iconList->icon(iconKey);
+ if (!mmcIcon || mmcIcon->isBuiltIn()) {
+ return;
+ }
+ auto path = mmcIcon->getFilePath();
+ if (!path.isNull()) {
+ QFileInfo inInfo(path);
+ FS::copy(path, FS::PathCombine(m_instance->instanceRoot(),
+ inInfo.fileName()))();
+ return;
+ }
+ auto& image = mmcIcon->m_images[mmcIcon->type()];
+ auto& icon = image.icon;
+ auto sizes = icon.availableSizes();
+ if (sizes.size() == 0) {
+ return;
+ }
+ auto areaOf = [](QSize size) { return size.width() * size.height(); };
+ QSize largest = sizes[0];
+ // find variant with largest area
+ for (auto size : sizes) {
+ if (areaOf(largest) < areaOf(size)) {
+ largest = size;
+ }
+ }
+ auto pixmap = icon.pixmap(largest);
+ pixmap.save(FS::PathCombine(m_instance->instanceRoot(), iconKey + ".png"));
+}
+
+bool ExportInstanceDialog::doExport()
+{
+ auto name = FS::RemoveInvalidFilenameChars(m_instance->name());
+
+ const QString output = QFileDialog::getSaveFileName(
+ this, tr("Export %1").arg(m_instance->name()),
+ FS::PathCombine(QDir::homePath(), name + ".zip"), "Zip (*.zip)",
+ nullptr, QFileDialog::DontConfirmOverwrite);
+ if (output.isEmpty()) {
+ return false;
+ }
+ if (QFile::exists(output)) {
+ int ret = QMessageBox::question(
+ this, tr("Overwrite?"),
+ tr("This file already exists. Do you want to overwrite it?"),
+ QMessageBox::No, QMessageBox::Yes);
+ if (ret == QMessageBox::No) {
+ return false;
+ }
+ }
+
+ SaveIcon(m_instance);
+
+ auto& blocked = proxyModel->blockedPaths();
+ using std::placeholders::_1;
+ if (!MMCZip::compressDir(
+ output, m_instance->instanceRoot(),
+ std::bind(&SeparatorPrefixTree<'/'>::covers, blocked, _1))) {
+ QMessageBox::warning(this, tr("Error"),
+ tr("Unable to export instance"));
+ return false;
+ }
+ return true;
+}
+
+void ExportInstanceDialog::done(int result)
+{
+ savePackIgnore();
+ if (result == QDialog::Accepted) {
+ if (doExport()) {
+ QDialog::done(QDialog::Accepted);
+ return;
+ } else {
+ return;
+ }
+ }
+ QDialog::done(result);
+}
+
+void ExportInstanceDialog::rowsInserted(QModelIndex parent, int top, int bottom)
+{
+ // WARNING: possible off-by-one?
+ for (int i = top; i < bottom; i++) {
+ auto node = parent.model()->index(i, 0, parent);
+ if (proxyModel->shouldExpand(node)) {
+ auto expNode = node.parent();
+ if (!expNode.isValid()) {
+ continue;
+ }
+ ui->treeView->expand(node);
+ }
+ }
+}
+
+QString ExportInstanceDialog::ignoreFileName()
+{
+ return FS::PathCombine(m_instance->instanceRoot(), ".packignore");
+}
+
+void ExportInstanceDialog::loadPackIgnore()
+{
+ auto filename = ignoreFileName();
+ QFile ignoreFile(filename);
+ if (!ignoreFile.open(QIODevice::ReadOnly)) {
+ return;
+ }
+ auto data = ignoreFile.readAll();
+ auto string = QString::fromUtf8(data);
+ proxyModel->setBlockedPaths(string.split('\n', Qt::SkipEmptyParts));
+}
+
+void ExportInstanceDialog::savePackIgnore()
+{
+ auto data = proxyModel->blockedPaths().toStringList().join('\n').toUtf8();
+ auto filename = ignoreFileName();
+ try {
+ FS::write(filename, data);
+ } catch (const Exception& e) {
+ qWarning() << e.cause();
+ }
+}
+
+#include "ExportInstanceDialog.moc"
diff --git a/meshmc/launcher/ui/dialogs/ExportInstanceDialog.h b/meshmc/launcher/ui/dialogs/ExportInstanceDialog.h
new file mode 100644
index 0000000000..e9e9549d4e
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/ExportInstanceDialog.h
@@ -0,0 +1,77 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <QModelIndex>
+#include <memory>
+
+class BaseInstance;
+class PackIgnoreProxy;
+typedef std::shared_ptr<BaseInstance> InstancePtr;
+
+namespace Ui
+{
+ class ExportInstanceDialog;
+}
+
+class ExportInstanceDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit ExportInstanceDialog(InstancePtr instance, QWidget* parent = 0);
+ ~ExportInstanceDialog();
+
+ virtual void done(int result);
+
+ private:
+ bool doExport();
+ void loadPackIgnore();
+ void savePackIgnore();
+ QString ignoreFileName();
+
+ private:
+ Ui::ExportInstanceDialog* ui;
+ InstancePtr m_instance;
+ PackIgnoreProxy* proxyModel;
+
+ private slots:
+ void rowsInserted(QModelIndex parent, int top, int bottom);
+};
diff --git a/meshmc/launcher/ui/dialogs/ExportInstanceDialog.ui b/meshmc/launcher/ui/dialogs/ExportInstanceDialog.ui
new file mode 100644
index 0000000000..bcd4e84a4d
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/ExportInstanceDialog.ui
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ExportInstanceDialog</class>
+ <widget class="QDialog" name="ExportInstanceDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>720</width>
+ <height>625</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Export Instance</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QTreeView" name="treeView">
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <property name="selectionMode">
+ <enum>QAbstractItemView::ExtendedSelection</enum>
+ </property>
+ <property name="sortingEnabled">
+ <bool>true</bool>
+ </property>
+ <attribute name="headerStretchLastSection">
+ <bool>false</bool>
+ </attribute>
+ </widget>
+ </item>
+ <item>
+ <widget class="QDialogButtonBox" name="buttonBox">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="standardButtons">
+ <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>treeView</tabstop>
+ </tabstops>
+ <resources/>
+ <connections>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>accepted()</signal>
+ <receiver>ExportInstanceDialog</receiver>
+ <slot>accept()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>248</x>
+ <y>254</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>157</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>rejected()</signal>
+ <receiver>ExportInstanceDialog</receiver>
+ <slot>reject()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>316</x>
+ <y>260</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>286</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ </connections>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/IconPickerDialog.cpp b/meshmc/launcher/ui/dialogs/IconPickerDialog.cpp
new file mode 100644
index 0000000000..1768b44fd4
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/IconPickerDialog.cpp
@@ -0,0 +1,197 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 <QKeyEvent>
+#include <QPushButton>
+#include <QFileDialog>
+
+#include "Application.h"
+
+#include "IconPickerDialog.h"
+#include "ui_IconPickerDialog.h"
+
+#include "ui/instanceview/InstanceDelegate.h"
+
+#include "icons/IconList.h"
+#include "icons/IconUtils.h"
+#include <DesktopServices.h>
+
+IconPickerDialog::IconPickerDialog(QWidget* parent)
+ : QDialog(parent), ui(new Ui::IconPickerDialog)
+{
+ ui->setupUi(this);
+ setWindowModality(Qt::WindowModal);
+
+ auto contentsWidget = ui->iconView;
+ contentsWidget->setViewMode(QListView::IconMode);
+ contentsWidget->setFlow(QListView::LeftToRight);
+ contentsWidget->setIconSize(QSize(48, 48));
+ contentsWidget->setMovement(QListView::Static);
+ contentsWidget->setResizeMode(QListView::Adjust);
+ contentsWidget->setSelectionMode(QAbstractItemView::SingleSelection);
+ contentsWidget->setSpacing(5);
+ contentsWidget->setWordWrap(false);
+ contentsWidget->setWrapping(true);
+ contentsWidget->setUniformItemSizes(true);
+ contentsWidget->setTextElideMode(Qt::ElideRight);
+ contentsWidget->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
+ contentsWidget->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn);
+ contentsWidget->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ contentsWidget->setItemDelegate(new ListViewDelegate());
+
+ // contentsWidget->setAcceptDrops(true);
+ contentsWidget->setDropIndicatorShown(true);
+ contentsWidget->viewport()->setAcceptDrops(true);
+ contentsWidget->setDragDropMode(QAbstractItemView::DropOnly);
+ contentsWidget->setDefaultDropAction(Qt::CopyAction);
+
+ contentsWidget->installEventFilter(this);
+
+ contentsWidget->setModel(APPLICATION->icons().get());
+
+ // NOTE: ResetRole forces the button to be on the left, while the OK/Cancel
+ // ones are on the right. We win.
+ auto buttonAdd =
+ ui->buttonBox->addButton(tr("Add Icon"), QDialogButtonBox::ResetRole);
+ auto buttonRemove = ui->buttonBox->addButton(tr("Remove Icon"),
+ QDialogButtonBox::ResetRole);
+
+ connect(buttonAdd, SIGNAL(clicked(bool)), SLOT(addNewIcon()));
+ connect(buttonRemove, SIGNAL(clicked(bool)), SLOT(removeSelectedIcon()));
+
+ connect(contentsWidget, SIGNAL(doubleClicked(QModelIndex)),
+ SLOT(activated(QModelIndex)));
+
+ connect(contentsWidget->selectionModel(),
+ SIGNAL(selectionChanged(QItemSelection, QItemSelection)),
+ SLOT(selectionChanged(QItemSelection, QItemSelection)));
+
+ auto buttonFolder = ui->buttonBox->addButton(tr("Open Folder"),
+ QDialogButtonBox::ResetRole);
+ connect(buttonFolder, &QPushButton::clicked, this,
+ &IconPickerDialog::openFolder);
+}
+
+bool IconPickerDialog::eventFilter(QObject* obj, QEvent* evt)
+{
+ if (obj != ui->iconView)
+ return QDialog::eventFilter(obj, evt);
+ if (evt->type() != QEvent::KeyPress) {
+ return QDialog::eventFilter(obj, evt);
+ }
+ QKeyEvent* keyEvent = static_cast<QKeyEvent*>(evt);
+ switch (keyEvent->key()) {
+ case Qt::Key_Delete:
+ removeSelectedIcon();
+ return true;
+ case Qt::Key_Plus:
+ addNewIcon();
+ return true;
+ default:
+ break;
+ }
+ return QDialog::eventFilter(obj, evt);
+}
+
+void IconPickerDialog::addNewIcon()
+{
+ //: The title of the select icons open file dialog
+ QString selectIcons = tr("Select Icons");
+ //: The type of icon files
+ auto filter = IconUtils::getIconFilter();
+ QStringList fileNames = QFileDialog::getOpenFileNames(
+ this, selectIcons, QString(), tr("Icons %1").arg(filter));
+ APPLICATION->icons()->installIcons(fileNames);
+}
+
+void IconPickerDialog::removeSelectedIcon()
+{
+ APPLICATION->icons()->deleteIcon(selectedIconKey);
+}
+
+void IconPickerDialog::activated(QModelIndex index)
+{
+ selectedIconKey = index.data(Qt::UserRole).toString();
+ accept();
+}
+
+void IconPickerDialog::selectionChanged(QItemSelection selected,
+ QItemSelection deselected)
+{
+ if (selected.empty())
+ return;
+
+ QString key =
+ selected.first().indexes().first().data(Qt::UserRole).toString();
+ if (!key.isEmpty()) {
+ selectedIconKey = key;
+ }
+}
+
+int IconPickerDialog::execWithSelection(QString selection)
+{
+ auto list = APPLICATION->icons();
+ auto contentsWidget = ui->iconView;
+ selectedIconKey = selection;
+
+ int index_nr = list->getIconIndex(selection);
+ auto model_index = list->index(index_nr);
+ contentsWidget->selectionModel()->select(model_index,
+ QItemSelectionModel::Current |
+ QItemSelectionModel::Select);
+
+ QMetaObject::invokeMethod(this, "delayed_scroll", Qt::QueuedConnection,
+ Q_ARG(QModelIndex, model_index));
+ return QDialog::exec();
+}
+
+void IconPickerDialog::delayed_scroll(QModelIndex model_index)
+{
+ auto contentsWidget = ui->iconView;
+ contentsWidget->scrollTo(model_index);
+}
+
+IconPickerDialog::~IconPickerDialog()
+{
+ delete ui;
+}
+
+void IconPickerDialog::openFolder()
+{
+ DesktopServices::openDirectory(APPLICATION->icons()->getDirectory(), true);
+}
diff --git a/meshmc/launcher/ui/dialogs/IconPickerDialog.h b/meshmc/launcher/ui/dialogs/IconPickerDialog.h
new file mode 100644
index 0000000000..0a92be4a53
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/IconPickerDialog.h
@@ -0,0 +1,71 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+#include <QDialog>
+#include <QItemSelection>
+
+namespace Ui
+{
+ class IconPickerDialog;
+}
+
+class IconPickerDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit IconPickerDialog(QWidget* parent = 0);
+ ~IconPickerDialog();
+ int execWithSelection(QString selection);
+ QString selectedIconKey;
+
+ protected:
+ virtual bool eventFilter(QObject*, QEvent*);
+
+ private:
+ Ui::IconPickerDialog* ui;
+
+ private slots:
+ void selectionChanged(QItemSelection, QItemSelection);
+ void activated(QModelIndex);
+ void delayed_scroll(QModelIndex);
+ void addNewIcon();
+ void removeSelectedIcon();
+ void openFolder();
+};
diff --git a/meshmc/launcher/ui/dialogs/IconPickerDialog.ui b/meshmc/launcher/ui/dialogs/IconPickerDialog.ui
new file mode 100644
index 0000000000..c548edfb7a
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/IconPickerDialog.ui
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>IconPickerDialog</class>
+ <widget class="QDialog" name="IconPickerDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>676</width>
+ <height>555</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Pick icon</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QListView" name="iconView"/>
+ </item>
+ <item>
+ <widget class="QDialogButtonBox" name="buttonBox">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="standardButtons">
+ <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>accepted()</signal>
+ <receiver>IconPickerDialog</receiver>
+ <slot>accept()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>248</x>
+ <y>254</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>157</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>rejected()</signal>
+ <receiver>IconPickerDialog</receiver>
+ <slot>reject()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>316</x>
+ <y>260</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>286</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ </connections>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/JavaDownloadDialog.cpp b/meshmc/launcher/ui/dialogs/JavaDownloadDialog.cpp
new file mode 100644
index 0000000000..c83542543c
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/JavaDownloadDialog.cpp
@@ -0,0 +1,399 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "JavaDownloadDialog.h"
+
+#include <QVBoxLayout>
+#include <QHBoxLayout>
+#include <QGroupBox>
+#include <QMessageBox>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonArray>
+#include <QDebug>
+#include <QDir>
+#include <QHeaderView>
+#include <QSplitter>
+
+#include "Application.h"
+#include "BuildConfig.h"
+#include "FileSystem.h"
+#include "Json.h"
+#include "java/JavaUtils.h"
+#include "net/Download.h"
+
+JavaDownloadDialog::JavaDownloadDialog(QWidget* parent) : QDialog(parent)
+{
+ setWindowTitle(tr("Download Java"));
+ setMinimumSize(700, 450);
+ resize(800, 500);
+ m_providers = JavaDownload::JavaProviderInfo::availableProviders();
+ setupUi();
+}
+
+void JavaDownloadDialog::setupUi()
+{
+ auto* mainLayout = new QVBoxLayout(this);
+
+ // --- Three-column selection area ---
+ auto* columnsLayout = new QHBoxLayout();
+
+ // Left: Provider list
+ auto* providerGroup = new QGroupBox(tr("Provider"), this);
+ auto* providerLayout = new QVBoxLayout(providerGroup);
+ m_providerList = new QListWidget(this);
+ m_providerList->setIconSize(QSize(24, 24));
+ for (const auto& provider : m_providers) {
+ auto* item = new QListWidgetItem(
+ APPLICATION->getThemedIcon(provider.iconName), provider.name);
+ m_providerList->addItem(item);
+ }
+ providerLayout->addWidget(m_providerList);
+ columnsLayout->addWidget(providerGroup, 1);
+
+ // Center: Major version list
+ auto* versionGroup = new QGroupBox(tr("Version"), this);
+ auto* versionLayout = new QVBoxLayout(versionGroup);
+ m_versionList = new QListWidget(this);
+ versionLayout->addWidget(m_versionList);
+ columnsLayout->addWidget(versionGroup, 1);
+
+ // Right: Sub-version / build list
+ auto* subVersionGroup = new QGroupBox(tr("Build"), this);
+ auto* subVersionLayout = new QVBoxLayout(subVersionGroup);
+ m_subVersionList = new QListWidget(this);
+ subVersionLayout->addWidget(m_subVersionList);
+ columnsLayout->addWidget(subVersionGroup, 1);
+
+ mainLayout->addLayout(columnsLayout, 1);
+
+ // Info label
+ m_infoLabel = new QLabel(this);
+ m_infoLabel->setWordWrap(true);
+ m_infoLabel->setText(
+ tr("Select a Java provider, version, and build to download."));
+ mainLayout->addWidget(m_infoLabel);
+
+ // Progress bar
+ m_progressBar = new QProgressBar(this);
+ m_progressBar->setVisible(false);
+ m_progressBar->setRange(0, 100);
+ mainLayout->addWidget(m_progressBar);
+
+ // Status label
+ m_statusLabel = new QLabel(this);
+ m_statusLabel->setVisible(false);
+ mainLayout->addWidget(m_statusLabel);
+
+ // Buttons
+ auto* buttonLayout = new QHBoxLayout();
+ buttonLayout->addStretch();
+
+ m_downloadBtn = new QPushButton(tr("Download"), this);
+ m_downloadBtn->setEnabled(false);
+ buttonLayout->addWidget(m_downloadBtn);
+
+ m_cancelBtn = new QPushButton(tr("Cancel"), this);
+ buttonLayout->addWidget(m_cancelBtn);
+
+ mainLayout->addLayout(buttonLayout);
+
+ // Connections
+ connect(m_providerList, &QListWidget::currentRowChanged, this,
+ &JavaDownloadDialog::providerChanged);
+ connect(m_versionList, &QListWidget::currentRowChanged, this,
+ &JavaDownloadDialog::majorVersionChanged);
+ connect(m_subVersionList, &QListWidget::currentRowChanged, this,
+ &JavaDownloadDialog::subVersionChanged);
+ connect(m_downloadBtn, &QPushButton::clicked, this,
+ &JavaDownloadDialog::onDownloadClicked);
+ connect(m_cancelBtn, &QPushButton::clicked, this,
+ &JavaDownloadDialog::onCancelClicked);
+
+ // Select first provider
+ if (m_providerList->count() > 0) {
+ m_providerList->setCurrentRow(0);
+ }
+}
+
+void JavaDownloadDialog::providerChanged(int index)
+{
+ if (index < 0 || index >= m_providers.size())
+ return;
+
+ m_versionList->clear();
+ m_subVersionList->clear();
+ m_downloadBtn->setEnabled(false);
+ m_versions.clear();
+ m_runtimes.clear();
+
+ const auto& provider = m_providers[index];
+ m_infoLabel->setText(tr("Loading versions for %1...").arg(provider.name));
+
+ fetchVersionList(provider.uid);
+}
+
+void JavaDownloadDialog::majorVersionChanged(int index)
+{
+ if (index < 0 || index >= m_versions.size())
+ return;
+
+ m_subVersionList->clear();
+ m_downloadBtn->setEnabled(false);
+ m_runtimes.clear();
+
+ const auto& version = m_versions[index];
+ m_infoLabel->setText(tr("Loading builds for %1...").arg(version.versionId));
+
+ fetchRuntimes(version.uid, version.versionId);
+}
+
+void JavaDownloadDialog::subVersionChanged(int index)
+{
+ if (index < 0 || index >= m_runtimes.size()) {
+ m_downloadBtn->setEnabled(false);
+ return;
+ }
+
+ const auto& rt = m_runtimes[index];
+ m_infoLabel->setText(tr("Ready to download: %1\n"
+ "Version: %2\n"
+ "Platform: %3\n"
+ "Checksum: %4")
+ .arg(rt.name, rt.version.toString(), rt.runtimeOS,
+ rt.checksumHash.isEmpty()
+ ? tr("None")
+ : tr("Yes (%1)").arg(rt.checksumType)));
+ m_downloadBtn->setEnabled(true);
+}
+
+void JavaDownloadDialog::fetchVersionList(const QString& uid)
+{
+ m_fetchJob.reset();
+ m_fetchData.clear();
+
+ QString url = QString("%1%2/index.json").arg(BuildConfig.META_URL, uid);
+ m_fetchJob = new NetJob(tr("Fetch Java versions"), APPLICATION->network());
+ auto dl = Net::Download::makeByteArray(QUrl(url), &m_fetchData);
+ m_fetchJob->addNetAction(dl);
+
+ connect(m_fetchJob.get(), &NetJob::succeeded, this, [this, uid]() {
+ m_fetchJob.reset();
+
+ QJsonDocument doc;
+ try {
+ doc = Json::requireDocument(m_fetchData);
+ } catch (const Exception& e) {
+ m_infoLabel->setText(
+ tr("Failed to parse version list: %1").arg(e.cause()));
+ return;
+ }
+ if (!doc.isObject()) {
+ m_infoLabel->setText(tr("Failed to parse version list."));
+ return;
+ }
+
+ m_versions = JavaDownload::parseVersionIndex(doc.object(), uid);
+ m_versionList->clear();
+
+ for (const auto& ver : m_versions) {
+ QString displayName = ver.versionId;
+ if (displayName.startsWith("java")) {
+ displayName = "Java " + displayName.mid(4);
+ }
+ m_versionList->addItem(displayName);
+ }
+
+ if (m_versions.size() > 0) {
+ m_infoLabel->setText(tr("Select a version."));
+ } else {
+ m_infoLabel->setText(
+ tr("No versions available for this provider."));
+ }
+ });
+
+ connect(m_fetchJob.get(), &NetJob::failed, this, [this](QString reason) {
+ m_fetchJob.reset();
+ m_infoLabel->setText(tr("Failed to load versions: %1").arg(reason));
+ });
+
+ m_fetchJob->start();
+}
+
+void JavaDownloadDialog::fetchRuntimes(const QString& uid,
+ const QString& versionId)
+{
+ m_fetchJob.reset();
+ m_fetchData.clear();
+
+ QString url =
+ QString("%1%2/%3.json").arg(BuildConfig.META_URL, uid, versionId);
+ m_fetchJob =
+ new NetJob(tr("Fetch Java runtime details"), APPLICATION->network());
+ auto dl = Net::Download::makeByteArray(QUrl(url), &m_fetchData);
+ m_fetchJob->addNetAction(dl);
+
+ connect(m_fetchJob.get(), &NetJob::succeeded, this, [this]() {
+ m_fetchJob.reset();
+
+ QJsonDocument doc;
+ try {
+ doc = Json::requireDocument(m_fetchData);
+ } catch (const Exception& e) {
+ m_infoLabel->setText(
+ tr("Failed to parse runtime details: %1").arg(e.cause()));
+ return;
+ }
+ if (!doc.isObject()) {
+ m_infoLabel->setText(tr("Failed to parse runtime details."));
+ return;
+ }
+
+ auto allRuntimes = JavaDownload::parseRuntimes(doc.object());
+ QString myOS = JavaDownload::currentRuntimeOS();
+
+ m_runtimes.clear();
+ m_subVersionList->clear();
+ for (const auto& rt : allRuntimes) {
+ if (rt.runtimeOS == myOS) {
+ m_runtimes.append(rt);
+ m_subVersionList->addItem(rt.version.toString());
+ }
+ }
+
+ if (m_runtimes.isEmpty()) {
+ m_infoLabel->setText(
+ tr("No builds available for your platform (%1).").arg(myOS));
+ m_downloadBtn->setEnabled(false);
+ } else {
+ m_infoLabel->setText(tr("Select a build to download."));
+ m_subVersionList->setCurrentRow(0);
+ }
+ });
+
+ connect(m_fetchJob.get(), &NetJob::failed, this, [this](QString reason) {
+ m_fetchJob.reset();
+ m_infoLabel->setText(
+ tr("Failed to load runtime details: %1").arg(reason));
+ });
+
+ m_fetchJob->start();
+}
+
+QString JavaDownloadDialog::javaInstallDir() const
+{
+ return JavaUtils::managedJavaRoot();
+}
+
+void JavaDownloadDialog::onDownloadClicked()
+{
+ int idx = m_subVersionList->currentRow();
+ if (idx < 0 || idx >= m_runtimes.size())
+ return;
+
+ const auto& runtime = m_runtimes[idx];
+
+ // Build target directory path: {dataPath}/java/{vendor}/{name}-{version}/
+ QString dirName =
+ QString("%1-%2").arg(runtime.name, runtime.version.toString());
+ QString targetDir =
+ FS::PathCombine(javaInstallDir(), runtime.vendor, dirName);
+
+ // Check if already installed
+ if (QDir(targetDir).exists()) {
+ auto result = QMessageBox::question(
+ this, tr("Already Installed"),
+ tr("This Java version appears to be already installed at:\n%1\n\n"
+ "Do you want to reinstall it?")
+ .arg(targetDir),
+ QMessageBox::Yes | QMessageBox::No);
+ if (result != QMessageBox::Yes) {
+ return;
+ }
+ // Remove existing installation
+ QDir(targetDir).removeRecursively();
+ }
+
+ m_downloadBtn->setEnabled(false);
+ m_providerList->setEnabled(false);
+ m_versionList->setEnabled(false);
+ m_subVersionList->setEnabled(false);
+ m_progressBar->setVisible(true);
+ m_progressBar->setValue(0);
+ m_statusLabel->setVisible(true);
+
+ m_downloadTask =
+ std::make_unique<JavaDownloadTask>(runtime, targetDir, this);
+
+ connect(m_downloadTask.get(), &Task::progress, this,
+ [this](qint64 current, qint64 total) {
+ if (total > 0) {
+ m_progressBar->setValue(
+ static_cast<int>(current * 100 / total));
+ }
+ });
+
+ connect(m_downloadTask.get(), &Task::status, this,
+ [this](const QString& status) { m_statusLabel->setText(status); });
+
+ connect(m_downloadTask.get(), &Task::succeeded, this, [this]() {
+ m_installedJavaPath = m_downloadTask->installedJavaPath();
+ m_progressBar->setValue(100);
+ m_statusLabel->setText(tr("Java installed successfully!"));
+
+ QMessageBox::information(
+ this, tr("Download Complete"),
+ tr("Java has been downloaded and installed successfully.\n\n"
+ "Java binary: %1")
+ .arg(m_installedJavaPath));
+
+ accept();
+ });
+
+ connect(m_downloadTask.get(), &Task::failed, this,
+ [this](const QString& reason) {
+ m_progressBar->setVisible(false);
+ m_statusLabel->setText(tr("Download failed: %1").arg(reason));
+ m_downloadBtn->setEnabled(true);
+ m_providerList->setEnabled(true);
+ m_versionList->setEnabled(true);
+ m_subVersionList->setEnabled(true);
+
+ QMessageBox::warning(
+ this, tr("Download Failed"),
+ tr("Failed to download Java:\n%1").arg(reason));
+ });
+
+ m_downloadTask->start();
+}
+
+void JavaDownloadDialog::onCancelClicked()
+{
+ if (m_fetchJob) {
+ m_fetchJob->abort();
+ m_fetchJob.reset();
+ }
+ if (m_downloadTask) {
+ m_downloadTask->abort();
+ m_downloadTask.reset();
+ }
+ reject();
+}
diff --git a/meshmc/launcher/ui/dialogs/JavaDownloadDialog.h b/meshmc/launcher/ui/dialogs/JavaDownloadDialog.h
new file mode 100644
index 0000000000..48d29a7328
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/JavaDownloadDialog.h
@@ -0,0 +1,82 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <QListWidget>
+#include <QPushButton>
+#include <QProgressBar>
+#include <QLabel>
+#include <QSplitter>
+
+#include "java/download/JavaRuntime.h"
+#include "java/download/JavaDownloadTask.h"
+#include "net/NetJob.h"
+
+class JavaDownloadDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit JavaDownloadDialog(QWidget* parent = nullptr);
+ ~JavaDownloadDialog() override = default;
+
+ QString installedJavaPath() const
+ {
+ return m_installedJavaPath;
+ }
+
+ private slots:
+ void providerChanged(int index);
+ void majorVersionChanged(int index);
+ void subVersionChanged(int index);
+ void onDownloadClicked();
+ void onCancelClicked();
+
+ private:
+ void setupUi();
+ void fetchVersionList(const QString& uid);
+ void fetchRuntimes(const QString& uid, const QString& versionId);
+ QString javaInstallDir() const;
+
+ // Left panel: providers
+ QListWidget* m_providerList = nullptr;
+ // Center panel: major versions (Java 25, Java 21, ...)
+ QListWidget* m_versionList = nullptr;
+ // Right panel: sub-versions / builds
+ QListWidget* m_subVersionList = nullptr;
+
+ QLabel* m_infoLabel = nullptr;
+ QLabel* m_statusLabel = nullptr;
+ QPushButton* m_downloadBtn = nullptr;
+ QPushButton* m_cancelBtn = nullptr;
+ QProgressBar* m_progressBar = nullptr;
+
+ QList<JavaDownload::JavaProviderInfo> m_providers;
+ QList<JavaDownload::JavaVersionInfo> m_versions;
+ QList<JavaDownload::RuntimeEntry> m_runtimes;
+
+ NetJob::Ptr m_fetchJob;
+ QByteArray m_fetchData;
+ std::unique_ptr<JavaDownloadTask> m_downloadTask;
+ QString m_installedJavaPath;
+};
diff --git a/meshmc/launcher/ui/dialogs/MSALoginDialog.cpp b/meshmc/launcher/ui/dialogs/MSALoginDialog.cpp
new file mode 100644
index 0000000000..2038ab8bdf
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/MSALoginDialog.cpp
@@ -0,0 +1,147 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "MSALoginDialog.h"
+#include "ui_MSALoginDialog.h"
+
+#include "minecraft/auth/AccountTask.h"
+
+#include <QtWidgets/QPushButton>
+#include <QUrl>
+
+MSALoginDialog::MSALoginDialog(QWidget* parent)
+ : QDialog(parent), ui(new Ui::MSALoginDialog)
+{
+ ui->setupUi(this);
+ ui->progressBar->setVisible(false);
+
+ connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
+ connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
+}
+
+int MSALoginDialog::exec()
+{
+ setUserInputsEnabled(false);
+ ui->progressBar->setVisible(true);
+ ui->progressBar->setMaximum(0); // Indeterminate progress
+ ui->label->setText(tr("Opening your browser for Microsoft login..."));
+
+ // Setup the login task and start it
+ m_account = MinecraftAccount::createBlankMSA();
+ m_loginTask = m_account->loginMSA();
+ connect(m_loginTask.get(), &Task::failed, this,
+ &MSALoginDialog::onTaskFailed);
+ connect(m_loginTask.get(), &Task::succeeded, this,
+ &MSALoginDialog::onTaskSucceeded);
+ connect(m_loginTask.get(), &Task::status, this,
+ &MSALoginDialog::onTaskStatus);
+ connect(m_loginTask.get(), &Task::progress, this,
+ &MSALoginDialog::onTaskProgress);
+ connect(m_loginTask.get(), &AccountTask::authorizeWithBrowser, this,
+ &MSALoginDialog::onAuthorizeWithBrowser);
+ m_loginTask->start();
+
+ return QDialog::exec();
+}
+
+MSALoginDialog::~MSALoginDialog()
+{
+ delete ui;
+}
+
+void MSALoginDialog::onAuthorizeWithBrowser(const QUrl& url)
+{
+ QString urlString = url.toString();
+ QString linkString =
+ QString("<a href=\"%1\">%2</a>").arg(urlString, tr("here"));
+ ui->label->setText(
+ tr("<p>A browser window will open for Microsoft login.</p>"
+ "<p>If it doesn't open automatically, click %1.</p>")
+ .arg(linkString));
+}
+
+void MSALoginDialog::setUserInputsEnabled(bool enable)
+{
+ ui->buttonBox->setEnabled(enable);
+}
+
+void MSALoginDialog::onTaskFailed(const QString& reason)
+{
+ // Set message
+ auto lines = reason.split('\n');
+ QString processed;
+ for (auto line : lines) {
+ if (line.size()) {
+ processed += "<font color='red'>" + line + "</font><br />";
+ } else {
+ processed += "<br />";
+ }
+ }
+ ui->label->setText(processed);
+
+ // Re-enable user-interaction
+ setUserInputsEnabled(true);
+ ui->progressBar->setVisible(false);
+}
+
+void MSALoginDialog::onTaskSucceeded()
+{
+ QDialog::accept();
+}
+
+void MSALoginDialog::onTaskStatus(const QString& status)
+{
+ ui->label->setText(status);
+}
+
+void MSALoginDialog::onTaskProgress(qint64 current, qint64 total)
+{
+ ui->progressBar->setMaximum(total);
+ ui->progressBar->setValue(current);
+}
+
+// Public interface
+MinecraftAccountPtr MSALoginDialog::newAccount(QWidget* parent, QString msg)
+{
+ MSALoginDialog dlg(parent);
+ dlg.ui->label->setText(msg);
+ if (dlg.exec() == QDialog::Accepted) {
+ return dlg.m_account;
+ }
+ return nullptr;
+}
diff --git a/meshmc/launcher/ui/dialogs/MSALoginDialog.h b/meshmc/launcher/ui/dialogs/MSALoginDialog.h
new file mode 100644
index 0000000000..b68074d232
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/MSALoginDialog.h
@@ -0,0 +1,77 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QtWidgets/QDialog>
+#include <QtCore/QEventLoop>
+
+#include "minecraft/auth/MinecraftAccount.h"
+
+namespace Ui
+{
+ class MSALoginDialog;
+}
+
+class MSALoginDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ ~MSALoginDialog();
+
+ static MinecraftAccountPtr newAccount(QWidget* parent, QString message);
+ int exec() override;
+
+ private:
+ explicit MSALoginDialog(QWidget* parent = 0);
+
+ void setUserInputsEnabled(bool enable);
+
+ protected slots:
+ void onTaskFailed(const QString& reason);
+ void onTaskSucceeded();
+ void onTaskStatus(const QString& status);
+ void onTaskProgress(qint64 current, qint64 total);
+ void onAuthorizeWithBrowser(const QUrl& url);
+
+ private:
+ Ui::MSALoginDialog* ui;
+ MinecraftAccountPtr m_account;
+ shared_qobject_ptr<AccountTask> m_loginTask;
+};
diff --git a/meshmc/launcher/ui/dialogs/MSALoginDialog.ui b/meshmc/launcher/ui/dialogs/MSALoginDialog.ui
new file mode 100644
index 0000000000..78cbfb269f
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/MSALoginDialog.ui
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MSALoginDialog</class>
+ <widget class="QDialog" name="MSALoginDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>491</width>
+ <height>143</height>
+ </rect>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="windowTitle">
+ <string>Add Microsoft Account</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string notr="true">Message label placeholder.
+
+aaaaa</string>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::RichText</enum>
+ </property>
+ <property name="openExternalLinks">
+ <bool>true</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QProgressBar" name="progressBar">
+ <property name="value">
+ <number>24</number>
+ </property>
+ <property name="textVisible">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QDialogButtonBox" name="buttonBox">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="standardButtons">
+ <set>QDialogButtonBox::Cancel</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/NewComponentDialog.cpp b/meshmc/launcher/ui/dialogs/NewComponentDialog.cpp
new file mode 100644
index 0000000000..f87e94afb6
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/NewComponentDialog.cpp
@@ -0,0 +1,129 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "Application.h"
+#include "NewComponentDialog.h"
+#include "ui_NewComponentDialog.h"
+
+#include <BaseVersion.h>
+#include <icons/IconList.h>
+#include <tasks/Task.h>
+#include <InstanceList.h>
+
+#include "VersionSelectDialog.h"
+#include "ProgressDialog.h"
+#include "IconPickerDialog.h"
+
+#include <QLayout>
+#include <QPushButton>
+#include <QFileDialog>
+#include <QValidator>
+
+#include <meta/Index.h>
+#include <meta/VersionList.h>
+
+NewComponentDialog::NewComponentDialog(const QString& initialName,
+ const QString& initialUid,
+ QWidget* parent)
+ : QDialog(parent), ui(new Ui::NewComponentDialog)
+{
+ ui->setupUi(this);
+ resize(minimumSizeHint());
+
+ ui->nameTextBox->setText(initialName);
+ ui->uidTextBox->setText(initialUid);
+
+ connect(ui->nameTextBox, &QLineEdit::textChanged, this,
+ &NewComponentDialog::updateDialogState);
+ connect(ui->uidTextBox, &QLineEdit::textChanged, this,
+ &NewComponentDialog::updateDialogState);
+
+ auto groups = APPLICATION->instances()->getGroups();
+ groups.removeDuplicates();
+ ui->nameTextBox->setFocus();
+
+ originalPlaceholderText = ui->uidTextBox->placeholderText();
+ updateDialogState();
+}
+
+NewComponentDialog::~NewComponentDialog()
+{
+ delete ui;
+}
+
+void NewComponentDialog::updateDialogState()
+{
+ auto protoUid = ui->nameTextBox->text().toLower();
+ protoUid.remove(QRegularExpression("[^a-z]"));
+ if (protoUid.isEmpty()) {
+ ui->uidTextBox->setPlaceholderText(originalPlaceholderText);
+ } else {
+ QString suggestedUid = "org.projecttick.custom." + protoUid;
+ ui->uidTextBox->setPlaceholderText(suggestedUid);
+ }
+ bool allowOK =
+ !name().isEmpty() && !uid().isEmpty() && !uidBlacklist.contains(uid());
+ ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(allowOK);
+}
+
+QString NewComponentDialog::name() const
+{
+ auto result = ui->nameTextBox->text();
+ if (result.size()) {
+ return result.trimmed();
+ }
+ return QString();
+}
+
+QString NewComponentDialog::uid() const
+{
+ auto result = ui->uidTextBox->text();
+ if (result.size()) {
+ return result.trimmed();
+ }
+ result = ui->uidTextBox->placeholderText();
+ if (result.size() && result != originalPlaceholderText) {
+ return result.trimmed();
+ }
+ return QString();
+}
+
+void NewComponentDialog::setBlacklist(QStringList badUids)
+{
+ uidBlacklist = badUids;
+}
diff --git a/meshmc/launcher/ui/dialogs/NewComponentDialog.h b/meshmc/launcher/ui/dialogs/NewComponentDialog.h
new file mode 100644
index 0000000000..7ad8b89ee2
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/NewComponentDialog.h
@@ -0,0 +1,73 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+
+#include <QString>
+#include <QStringList>
+
+namespace Ui
+{
+ class NewComponentDialog;
+}
+
+class NewComponentDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit NewComponentDialog(const QString& initialName = QString(),
+ const QString& initialUid = QString(),
+ QWidget* parent = 0);
+ virtual ~NewComponentDialog();
+ void setBlacklist(QStringList badUids);
+
+ QString name() const;
+ QString uid() const;
+
+ private slots:
+ void updateDialogState();
+
+ private:
+ Ui::NewComponentDialog* ui;
+
+ QString originalPlaceholderText;
+ QStringList uidBlacklist;
+};
diff --git a/meshmc/launcher/ui/dialogs/NewComponentDialog.ui b/meshmc/launcher/ui/dialogs/NewComponentDialog.ui
new file mode 100644
index 0000000000..03b0d22294
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/NewComponentDialog.ui
@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>NewComponentDialog</class>
+ <widget class="QDialog" name="NewComponentDialog">
+ <property name="windowModality">
+ <enum>Qt::ApplicationModal</enum>
+ </property>
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>345</width>
+ <height>146</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Add Empty Component</string>
+ </property>
+ <property name="windowIcon">
+ <iconset>
+ <normaloff>:/icons/toolbar/copy</normaloff>:/icons/toolbar/copy</iconset>
+ </property>
+ <property name="modal">
+ <bool>true</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QLineEdit" name="nameTextBox">
+ <property name="placeholderText">
+ <string>Name</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLineEdit" name="uidTextBox">
+ <property name="placeholderText">
+ <string>uid</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="Line" name="line">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QDialogButtonBox" name="buttonBox">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="standardButtons">
+ <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>nameTextBox</tabstop>
+ <tabstop>uidTextBox</tabstop>
+ </tabstops>
+ <resources>
+ <include location="../../graphics.qrc"/>
+ </resources>
+ <connections>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>accepted()</signal>
+ <receiver>NewComponentDialog</receiver>
+ <slot>accept()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>248</x>
+ <y>254</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>157</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ <connection>
+ <sender>buttonBox</sender>
+ <signal>rejected()</signal>
+ <receiver>NewComponentDialog</receiver>
+ <slot>reject()</slot>
+ <hints>
+ <hint type="sourcelabel">
+ <x>316</x>
+ <y>260</y>
+ </hint>
+ <hint type="destinationlabel">
+ <x>286</x>
+ <y>274</y>
+ </hint>
+ </hints>
+ </connection>
+ </connections>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/NewInstanceDialog.cpp b/meshmc/launcher/ui/dialogs/NewInstanceDialog.cpp
new file mode 100644
index 0000000000..571bc3717e
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/NewInstanceDialog.cpp
@@ -0,0 +1,278 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "Application.h"
+#include "NewInstanceDialog.h"
+#include "ui_NewInstanceDialog.h"
+
+#include <BaseVersion.h>
+#include <icons/IconList.h>
+#include <tasks/Task.h>
+#include <InstanceList.h>
+
+#include "VersionSelectDialog.h"
+#include "ProgressDialog.h"
+#include "IconPickerDialog.h"
+
+#include <QLayout>
+#include <QPushButton>
+#include <QFileDialog>
+#include <QValidator>
+#include <QDialogButtonBox>
+
+#include "ui/widgets/PageContainer.h"
+#include "ui/pages/modplatform/VanillaPage.h"
+#include "ui/pages/modplatform/atlauncher/AtlPage.h"
+#include "ui/pages/modplatform/ftb/FtbPage.h"
+#include "ui/pages/modplatform/legacy_ftb/Page.h"
+#include "ui/pages/modplatform/flame/FlamePage.h"
+#include "ui/pages/modplatform/modrinth/ModrinthPage.h"
+#include "ui/pages/modplatform/ImportPage.h"
+#include "ui/pages/modplatform/technic/TechnicPage.h"
+
+NewInstanceDialog::NewInstanceDialog(const QString& initialGroup,
+ const QString& url, QWidget* parent)
+ : QDialog(parent), ui(new Ui::NewInstanceDialog)
+{
+ ui->setupUi(this);
+
+ setWindowIcon(APPLICATION->getThemedIcon("new"));
+
+ InstIconKey = "default";
+ ui->iconButton->setIcon(APPLICATION->icons()->getIcon(InstIconKey));
+
+ auto groupList = APPLICATION->instances()->getGroups();
+ groupList.removeDuplicates();
+ groupList.sort(Qt::CaseInsensitive);
+ groupList.removeOne("");
+ groupList.push_front(initialGroup);
+ groupList.push_front("");
+ ui->groupBox->addItems(groupList);
+ int index = groupList.indexOf(initialGroup);
+ if (index == -1) {
+ index = 0;
+ }
+ ui->groupBox->setCurrentIndex(index);
+ ui->groupBox->lineEdit()->setPlaceholderText(tr("No group"));
+
+ // 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);
+ ui->verticalLayout->insertWidget(2, 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, &NewInstanceDialog::accept);
+
+ auto CancelButton = m_buttons->button(QDialogButtonBox::Cancel);
+ CancelButton->setDefault(false);
+ CancelButton->setAutoDefault(false);
+ connect(CancelButton, &QPushButton::clicked, this,
+ &NewInstanceDialog::reject);
+
+ auto HelpButton = m_buttons->button(QDialogButtonBox::Help);
+ HelpButton->setDefault(false);
+ HelpButton->setAutoDefault(false);
+ connect(HelpButton, &QPushButton::clicked, m_container,
+ &PageContainer::help);
+
+ if (!url.isEmpty()) {
+ QUrl actualUrl(url);
+ m_container->selectPage("import");
+ importPage->setUrl(url);
+ }
+
+ updateDialogState();
+
+ restoreGeometry(QByteArray::fromBase64(
+ APPLICATION->settings()->get("NewInstanceGeometry").toByteArray()));
+}
+
+void NewInstanceDialog::reject()
+{
+ APPLICATION->settings()->set("NewInstanceGeometry",
+ saveGeometry().toBase64());
+ QDialog::reject();
+}
+
+void NewInstanceDialog::accept()
+{
+ APPLICATION->settings()->set("NewInstanceGeometry",
+ saveGeometry().toBase64());
+ importIconNow();
+ QDialog::accept();
+}
+
+QList<BasePage*> NewInstanceDialog::getPages()
+{
+ importPage = new ImportPage(this);
+ flamePage = new FlamePage(this);
+ auto technicPage = new TechnicPage(this);
+ return {new VanillaPage(this), importPage,
+ new AtlPage(this), flamePage,
+ new ModrinthPage(this), new FtbPage(this),
+ new LegacyFTB::Page(this), technicPage};
+}
+
+QString NewInstanceDialog::dialogTitle()
+{
+ return tr("New Instance");
+}
+
+NewInstanceDialog::~NewInstanceDialog()
+{
+ delete ui;
+}
+
+void NewInstanceDialog::setSuggestedPack(const QString& name,
+ InstanceTask* task)
+{
+ creationTask.reset(task);
+ ui->instNameTextBox->setPlaceholderText(name);
+
+ if (!task) {
+ ui->iconButton->setIcon(APPLICATION->icons()->getIcon("default"));
+ importIcon = false;
+ }
+
+ auto allowOK = task && !instName().isEmpty();
+ m_buttons->button(QDialogButtonBox::Ok)->setEnabled(allowOK);
+}
+
+void NewInstanceDialog::setSuggestedIconFromFile(const QString& path,
+ const QString& name)
+{
+ importIcon = true;
+ importIconPath = path;
+ importIconName = name;
+
+ // Hmm, for some reason they can be to small
+ ui->iconButton->setIcon(QIcon(path));
+}
+
+void NewInstanceDialog::setSuggestedIcon(const QString& key)
+{
+ auto icon = APPLICATION->icons()->getIcon(key);
+ importIcon = false;
+
+ ui->iconButton->setIcon(icon);
+}
+
+InstanceTask* NewInstanceDialog::extractTask()
+{
+ InstanceTask* extracted = creationTask.get();
+ creationTask.release();
+ extracted->setName(instName());
+ extracted->setGroup(instGroup());
+ extracted->setIcon(iconKey());
+ return extracted;
+}
+
+void NewInstanceDialog::updateDialogState()
+{
+ auto allowOK = creationTask && !instName().isEmpty();
+ auto OkButton = m_buttons->button(QDialogButtonBox::Ok);
+ if (OkButton->isEnabled() != allowOK) {
+ OkButton->setEnabled(allowOK);
+ }
+}
+
+QString NewInstanceDialog::instName() const
+{
+ auto result = ui->instNameTextBox->text().trimmed();
+ if (result.size()) {
+ return result;
+ }
+ result = ui->instNameTextBox->placeholderText().trimmed();
+ if (result.size()) {
+ return result;
+ }
+ return QString();
+}
+
+QString NewInstanceDialog::instGroup() const
+{
+ return ui->groupBox->currentText();
+}
+QString NewInstanceDialog::iconKey() const
+{
+ return InstIconKey;
+}
+
+void NewInstanceDialog::on_iconButton_clicked()
+{
+ importIconNow(); // so the user can switch back
+ IconPickerDialog dlg(this);
+ dlg.execWithSelection(InstIconKey);
+
+ if (dlg.result() == QDialog::Accepted) {
+ InstIconKey = dlg.selectedIconKey;
+ ui->iconButton->setIcon(APPLICATION->icons()->getIcon(InstIconKey));
+ importIcon = false;
+ }
+}
+
+void NewInstanceDialog::on_instNameTextBox_textChanged(const QString& arg1)
+{
+ updateDialogState();
+}
+
+void NewInstanceDialog::importIconNow()
+{
+ if (importIcon) {
+ APPLICATION->icons()->installIcon(importIconPath, importIconName);
+ InstIconKey = importIconName;
+ importIcon = false;
+ }
+ APPLICATION->settings()->set("NewInstanceGeometry",
+ saveGeometry().toBase64());
+}
diff --git a/meshmc/launcher/ui/dialogs/NewInstanceDialog.h b/meshmc/launcher/ui/dialogs/NewInstanceDialog.h
new file mode 100644
index 0000000000..ba042eca1a
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/NewInstanceDialog.h
@@ -0,0 +1,106 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+
+#include "BaseVersion.h"
+#include "ui/pages/BasePageProvider.h"
+#include "InstanceTask.h"
+
+namespace Ui
+{
+ class NewInstanceDialog;
+}
+
+class PageContainer;
+class QDialogButtonBox;
+class ImportPage;
+class FlamePage;
+
+class NewInstanceDialog : public QDialog, public BasePageProvider
+{
+ Q_OBJECT
+
+ public:
+ explicit NewInstanceDialog(const QString& initialGroup,
+ const QString& url = QString(),
+ QWidget* parent = 0);
+ ~NewInstanceDialog();
+
+ void updateDialogState();
+
+ void setSuggestedPack(const QString& name = QString(),
+ InstanceTask* task = nullptr);
+ void setSuggestedIconFromFile(const QString& path, const QString& name);
+ void setSuggestedIcon(const QString& key);
+
+ InstanceTask* extractTask();
+
+ QString dialogTitle() override;
+ QList<BasePage*> getPages() override;
+
+ QString instName() const;
+ QString instGroup() const;
+ QString iconKey() const;
+
+ public slots:
+ void accept() override;
+ void reject() override;
+
+ private slots:
+ void on_iconButton_clicked();
+ void on_instNameTextBox_textChanged(const QString& arg1);
+
+ private:
+ Ui::NewInstanceDialog* ui = nullptr;
+ PageContainer* m_container = nullptr;
+ QDialogButtonBox* m_buttons = nullptr;
+
+ QString InstIconKey;
+ ImportPage* importPage = nullptr;
+ FlamePage* flamePage = nullptr;
+ std::unique_ptr<InstanceTask> creationTask;
+
+ bool importIcon = false;
+ QString importIconPath;
+ QString importIconName;
+
+ void importIconNow();
+};
diff --git a/meshmc/launcher/ui/dialogs/NewInstanceDialog.ui b/meshmc/launcher/ui/dialogs/NewInstanceDialog.ui
new file mode 100644
index 0000000000..7fb19ff5cf
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/NewInstanceDialog.ui
@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>NewInstanceDialog</class>
+ <widget class="QDialog" name="NewInstanceDialog">
+ <property name="windowModality">
+ <enum>Qt::ApplicationModal</enum>
+ </property>
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>730</width>
+ <height>127</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>New Instance</string>
+ </property>
+ <property name="windowIcon">
+ <iconset>
+ <normaloff>:/icons/toolbar/new</normaloff>:/icons/toolbar/new</iconset>
+ </property>
+ <property name="modal">
+ <bool>true</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="1" column="2">
+ <widget class="QComboBox" name="groupBox">
+ <property name="editable">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLabel" name="groupLabel">
+ <property name="text">
+ <string>&amp;Group:</string>
+ </property>
+ <property name="buddy">
+ <cstring>groupBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="2">
+ <widget class="QLineEdit" name="instNameTextBox"/>
+ </item>
+ <item row="0" column="1">
+ <widget class="QLabel" name="nameLabel">
+ <property name="text">
+ <string>&amp;Name:</string>
+ </property>
+ <property name="buddy">
+ <cstring>instNameTextBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0" rowspan="2">
+ <widget class="QToolButton" name="iconButton">
+ <property name="iconSize">
+ <size>
+ <width>80</width>
+ <height>80</height>
+ </size>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="Line" name="line">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>iconButton</tabstop>
+ <tabstop>instNameTextBox</tabstop>
+ <tabstop>groupBox</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/NotificationDialog.cpp b/meshmc/launcher/ui/dialogs/NotificationDialog.cpp
new file mode 100644
index 0000000000..6d43d09ebd
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/NotificationDialog.cpp
@@ -0,0 +1,103 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "NotificationDialog.h"
+#include "ui_NotificationDialog.h"
+
+#include <QTimerEvent>
+#include <QStyle>
+
+NotificationDialog::NotificationDialog(
+ const NotificationChecker::NotificationEntry& entry, QWidget* parent)
+ : QDialog(parent, Qt::MSWindowsFixedSizeDialogHint | Qt::WindowTitleHint |
+ Qt::CustomizeWindowHint),
+ ui(new Ui::NotificationDialog)
+{
+ ui->setupUi(this);
+
+ QStyle::StandardPixmap icon;
+ switch (entry.type) {
+ case NotificationChecker::NotificationEntry::Critical:
+ icon = QStyle::SP_MessageBoxCritical;
+ break;
+ case NotificationChecker::NotificationEntry::Warning:
+ icon = QStyle::SP_MessageBoxWarning;
+ break;
+ default:
+ case NotificationChecker::NotificationEntry::Information:
+ icon = QStyle::SP_MessageBoxInformation;
+ break;
+ }
+ ui->iconLabel->setPixmap(style()->standardPixmap(icon, 0, this));
+ ui->messageLabel->setText(entry.message);
+
+ m_dontShowAgainText = tr("Don't show again");
+ m_closeText = tr("Close");
+
+ ui->dontShowAgainBtn->setText(m_dontShowAgainText +
+ QString(" (%1)").arg(m_dontShowAgainTime));
+ ui->closeBtn->setText(m_closeText + QString(" (%1)").arg(m_closeTime));
+
+ startTimer(1000);
+}
+
+NotificationDialog::~NotificationDialog()
+{
+ delete ui;
+}
+
+void NotificationDialog::timerEvent(QTimerEvent* event)
+{
+ if (m_dontShowAgainTime > 0) {
+ m_dontShowAgainTime--;
+ if (m_dontShowAgainTime == 0) {
+ ui->dontShowAgainBtn->setText(m_dontShowAgainText);
+ ui->dontShowAgainBtn->setEnabled(true);
+ } else {
+ ui->dontShowAgainBtn->setText(
+ m_dontShowAgainText +
+ QString(" (%1)").arg(m_dontShowAgainTime));
+ }
+ }
+ if (m_closeTime > 0) {
+ m_closeTime--;
+ if (m_closeTime == 0) {
+ ui->closeBtn->setText(m_closeText);
+ ui->closeBtn->setEnabled(true);
+ } else {
+ ui->closeBtn->setText(m_closeText +
+ QString(" (%1)").arg(m_closeTime));
+ }
+ }
+
+ if (m_closeTime == 0 && m_dontShowAgainTime == 0) {
+ killTimer(event->timerId());
+ }
+}
+
+void NotificationDialog::on_dontShowAgainBtn_clicked()
+{
+ done(DontShowAgain);
+}
+void NotificationDialog::on_closeBtn_clicked()
+{
+ done(Normal);
+}
diff --git a/meshmc/launcher/ui/dialogs/NotificationDialog.h b/meshmc/launcher/ui/dialogs/NotificationDialog.h
new file mode 100644
index 0000000000..82f578f5fb
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/NotificationDialog.h
@@ -0,0 +1,63 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#ifndef NOTIFICATIONDIALOG_H
+#define NOTIFICATIONDIALOG_H
+
+#include <QDialog>
+
+#include "notifications/NotificationChecker.h"
+
+namespace Ui
+{
+ class NotificationDialog;
+}
+
+class NotificationDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit NotificationDialog(
+ const NotificationChecker::NotificationEntry& entry,
+ QWidget* parent = 0);
+ ~NotificationDialog();
+
+ enum ExitCode { Normal, DontShowAgain };
+
+ protected:
+ void timerEvent(QTimerEvent* event);
+
+ private:
+ Ui::NotificationDialog* ui;
+
+ int m_dontShowAgainTime = 10;
+ int m_closeTime = 5;
+
+ QString m_dontShowAgainText;
+ QString m_closeText;
+
+ private slots:
+ void on_dontShowAgainBtn_clicked();
+ void on_closeBtn_clicked();
+};
+
+#endif // NOTIFICATIONDIALOG_H
diff --git a/meshmc/launcher/ui/dialogs/NotificationDialog.ui b/meshmc/launcher/ui/dialogs/NotificationDialog.ui
new file mode 100644
index 0000000000..3e6c22bc80
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/NotificationDialog.ui
@@ -0,0 +1,85 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>NotificationDialog</class>
+ <widget class="QDialog" name="NotificationDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>320</width>
+ <height>240</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Notification</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout" stretch="1,0">
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_2" stretch="0,1">
+ <item>
+ <widget class="QLabel" name="iconLabel">
+ <property name="text">
+ <string notr="true">TextLabel</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="messageLabel">
+ <property name="text">
+ <string notr="true">TextLabel</string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ <property name="openExternalLinks">
+ <bool>true</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::TextBrowserInteraction</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <item>
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QPushButton" name="dontShowAgainBtn">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string>Don't show again</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="closeBtn">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string>Close</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/ProfileSelectDialog.cpp b/meshmc/launcher/ui/dialogs/ProfileSelectDialog.cpp
new file mode 100644
index 0000000000..77687c699e
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/ProfileSelectDialog.cpp
@@ -0,0 +1,137 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "ProfileSelectDialog.h"
+#include "ui_ProfileSelectDialog.h"
+
+#include <QItemSelectionModel>
+#include <QDebug>
+
+#include "SkinUtils.h"
+#include "Application.h"
+
+#include "ui/dialogs/ProgressDialog.h"
+
+ProfileSelectDialog::ProfileSelectDialog(const QString& message, int flags,
+ QWidget* parent)
+ : QDialog(parent), ui(new Ui::ProfileSelectDialog)
+{
+ ui->setupUi(this);
+
+ m_accounts = APPLICATION->accounts();
+ auto view = ui->listView;
+ // view->setModel(m_accounts.get());
+ // view->hideColumn(AccountList::ActiveColumn);
+ view->setColumnCount(1);
+ view->setRootIsDecorated(false);
+ // FIXME: use a real model, not this
+ if (QTreeWidgetItem* header = view->headerItem()) {
+ header->setText(0, tr("Name"));
+ } else {
+ view->setHeaderLabel(tr("Name"));
+ }
+ QList<QTreeWidgetItem*> items;
+ for (int i = 0; i < m_accounts->count(); i++) {
+ MinecraftAccountPtr account = m_accounts->at(i);
+ QString profileLabel;
+ if (account->isInUse()) {
+ profileLabel = tr("%1 (in use)").arg(account->profileName());
+ } else {
+ profileLabel = account->profileName();
+ }
+ auto item = new QTreeWidgetItem(view);
+ item->setText(0, profileLabel);
+ item->setIcon(0, account->getFace());
+ item->setData(0, AccountList::PointerRole,
+ QVariant::fromValue(account));
+ items.append(item);
+ }
+ view->addTopLevelItems(items);
+
+ // Set the message label.
+ ui->msgLabel->setVisible(!message.isEmpty());
+ ui->msgLabel->setText(message);
+
+ // Flags...
+ ui->globalDefaultCheck->setVisible(flags & GlobalDefaultCheckbox);
+ ui->instDefaultCheck->setVisible(flags & InstanceDefaultCheckbox);
+ qDebug() << flags;
+
+ // Select the first entry in the list.
+ ui->listView->setCurrentIndex(ui->listView->model()->index(0, 0));
+
+ connect(ui->listView, SIGNAL(doubleClicked(QModelIndex)),
+ SLOT(on_buttonBox_accepted()));
+}
+
+ProfileSelectDialog::~ProfileSelectDialog()
+{
+ delete ui;
+}
+
+MinecraftAccountPtr ProfileSelectDialog::selectedAccount() const
+{
+ return m_selected;
+}
+
+bool ProfileSelectDialog::useAsGlobalDefault() const
+{
+ return ui->globalDefaultCheck->isChecked();
+}
+
+bool ProfileSelectDialog::useAsInstDefaullt() const
+{
+ return ui->instDefaultCheck->isChecked();
+}
+
+void ProfileSelectDialog::on_buttonBox_accepted()
+{
+ QModelIndexList selection =
+ ui->listView->selectionModel()->selectedIndexes();
+ if (selection.size() > 0) {
+ QModelIndex selected = selection.first();
+ m_selected = selected.data(AccountList::PointerRole)
+ .value<MinecraftAccountPtr>();
+ }
+ close();
+}
+
+void ProfileSelectDialog::on_buttonBox_rejected()
+{
+ close();
+}
diff --git a/meshmc/launcher/ui/dialogs/ProfileSelectDialog.h b/meshmc/launcher/ui/dialogs/ProfileSelectDialog.h
new file mode 100644
index 0000000000..f121dfcc15
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/ProfileSelectDialog.h
@@ -0,0 +1,115 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+
+#include <memory>
+
+#include "minecraft/auth/AccountList.h"
+
+namespace Ui
+{
+ class ProfileSelectDialog;
+}
+
+class ProfileSelectDialog : public QDialog
+{
+ Q_OBJECT
+ public:
+ enum Flags {
+ NoFlags = 0,
+
+ /*!
+ * Shows a check box on the dialog that allows the user to specify that
+ * the account they've selected should be used as the global default for
+ * all instances.
+ */
+ GlobalDefaultCheckbox,
+
+ /*!
+ * Shows a check box on the dialog that allows the user to specify that
+ * the account they've selected should be used as the default for the
+ * instance they are currently launching. This is not currently
+ * implemented.
+ */
+ InstanceDefaultCheckbox,
+ };
+
+ /*!
+ * Constructs a new account select dialog with the given parent and message.
+ * The message will be shown at the top of the dialog. It is an empty string
+ * by default.
+ */
+ explicit ProfileSelectDialog(const QString& message = "", int flags = 0,
+ QWidget* parent = 0);
+ ~ProfileSelectDialog();
+
+ /*!
+ * Gets a pointer to the account that the user selected.
+ * This is null if the user clicked cancel or hasn't clicked OK yet.
+ */
+ MinecraftAccountPtr selectedAccount() const;
+
+ /*!
+ * Returns true if the user checked the "use as global default" checkbox.
+ * If the checkbox wasn't shown, this function returns false.
+ */
+ bool useAsGlobalDefault() const;
+
+ /*!
+ * Returns true if the user checked the "use as instance default" checkbox.
+ * If the checkbox wasn't shown, this function returns false.
+ */
+ bool useAsInstDefaullt() const;
+
+ public slots:
+ void on_buttonBox_accepted();
+
+ void on_buttonBox_rejected();
+
+ protected:
+ shared_qobject_ptr<AccountList> m_accounts;
+
+ //! The account that was selected when the user clicked OK.
+ MinecraftAccountPtr m_selected;
+
+ private:
+ Ui::ProfileSelectDialog* ui;
+};
diff --git a/meshmc/launcher/ui/dialogs/ProfileSelectDialog.ui b/meshmc/launcher/ui/dialogs/ProfileSelectDialog.ui
new file mode 100644
index 0000000000..e779b51bf1
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/ProfileSelectDialog.ui
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ProfileSelectDialog</class>
+ <widget class="QDialog" name="ProfileSelectDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>465</width>
+ <height>300</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Select an Account</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QLabel" name="msgLabel">
+ <property name="text">
+ <string>Select a profile.</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QTreeWidget" name="listView">
+ <column>
+ <property name="text">
+ <string notr="true">1</string>
+ </property>
+ </column>
+ </widget>
+ </item>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <item>
+ <widget class="QCheckBox" name="globalDefaultCheck">
+ <property name="text">
+ <string>Use as default?</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="instDefaultCheck">
+ <property name="text">
+ <string>Use as default for this instance only?</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QDialogButtonBox" name="buttonBox">
+ <property name="standardButtons">
+ <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/ProfileSetupDialog.cpp b/meshmc/launcher/ui/dialogs/ProfileSetupDialog.cpp
new file mode 100644
index 0000000000..9798af8199
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/ProfileSetupDialog.cpp
@@ -0,0 +1,298 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "ProfileSetupDialog.h"
+#include "ui_ProfileSetupDialog.h"
+
+#include <QPushButton>
+#include <QAction>
+#include <QRegularExpressionValidator>
+#include <QJsonDocument>
+#include <QDebug>
+
+#include "ui/dialogs/ProgressDialog.h"
+
+#include <Application.h>
+#include "minecraft/auth/AuthRequest.h"
+#include "minecraft/auth/Parsers.h"
+
+ProfileSetupDialog::ProfileSetupDialog(MinecraftAccountPtr accountToSetup,
+ QWidget* parent)
+ : QDialog(parent), m_accountToSetup(accountToSetup),
+ ui(new Ui::ProfileSetupDialog)
+{
+ ui->setupUi(this);
+ ui->errorLabel->setVisible(false);
+
+ goodIcon = APPLICATION->getThemedIcon("status-good");
+ yellowIcon = APPLICATION->getThemedIcon("status-yellow");
+ badIcon = APPLICATION->getThemedIcon("status-bad");
+
+ QRegularExpression permittedNames("[a-zA-Z0-9_]{3,16}");
+ auto nameEdit = ui->nameEdit;
+ nameEdit->setValidator(new QRegularExpressionValidator(permittedNames));
+ nameEdit->setClearButtonEnabled(true);
+ validityAction =
+ nameEdit->addAction(yellowIcon, QLineEdit::LeadingPosition);
+ connect(nameEdit, &QLineEdit::textEdited, this,
+ &ProfileSetupDialog::nameEdited);
+
+ checkStartTimer.setSingleShot(true);
+ connect(&checkStartTimer, &QTimer::timeout, this,
+ &ProfileSetupDialog::startCheck);
+
+ setNameStatus(NameStatus::NotSet, QString());
+}
+
+ProfileSetupDialog::~ProfileSetupDialog()
+{
+ delete ui;
+}
+
+void ProfileSetupDialog::on_buttonBox_accepted()
+{
+ setupProfile(currentCheck);
+}
+
+void ProfileSetupDialog::on_buttonBox_rejected()
+{
+ reject();
+}
+
+void ProfileSetupDialog::setNameStatus(ProfileSetupDialog::NameStatus status,
+ QString errorString = QString())
+{
+ nameStatus = status;
+ auto okButton = ui->buttonBox->button(QDialogButtonBox::Ok);
+ switch (nameStatus) {
+ case NameStatus::Available: {
+ validityAction->setIcon(goodIcon);
+ okButton->setEnabled(true);
+ } break;
+ case NameStatus::NotSet:
+ case NameStatus::Pending:
+ validityAction->setIcon(yellowIcon);
+ okButton->setEnabled(false);
+ break;
+ case NameStatus::Exists:
+ case NameStatus::Error:
+ validityAction->setIcon(badIcon);
+ okButton->setEnabled(false);
+ break;
+ }
+ if (!errorString.isEmpty()) {
+ ui->errorLabel->setText(errorString);
+ ui->errorLabel->setVisible(true);
+ } else {
+ ui->errorLabel->setVisible(false);
+ }
+}
+
+void ProfileSetupDialog::nameEdited(const QString& name)
+{
+ if (!ui->nameEdit->hasAcceptableInput()) {
+ setNameStatus(NameStatus::NotSet,
+ tr("Name is too short - must be between 3 and 16 "
+ "characters long."));
+ return;
+ }
+ scheduleCheck(name);
+}
+
+void ProfileSetupDialog::scheduleCheck(const QString& name)
+{
+ queuedCheck = name;
+ setNameStatus(NameStatus::Pending);
+ checkStartTimer.start(1000);
+}
+
+void ProfileSetupDialog::startCheck()
+{
+ if (isChecking) {
+ return;
+ }
+ if (queuedCheck.isNull()) {
+ return;
+ }
+ checkName(queuedCheck);
+}
+
+void ProfileSetupDialog::checkName(const QString& name)
+{
+ if (isChecking) {
+ return;
+ }
+
+ currentCheck = name;
+ isChecking = true;
+
+ auto token = m_accountToSetup->accessToken();
+
+ auto url = QString("https://api.minecraftservices.com/minecraft/profile/"
+ "name/%1/available")
+ .arg(name);
+ QNetworkRequest request = QNetworkRequest(url);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ request.setRawHeader("Accept", "application/json");
+ request.setRawHeader("Authorization",
+ QString("Bearer %1").arg(token).toUtf8());
+
+ AuthRequest* requestor = new AuthRequest(this);
+ connect(requestor, &AuthRequest::finished, this,
+ &ProfileSetupDialog::checkFinished);
+ requestor->get(request);
+}
+
+void ProfileSetupDialog::checkFinished(
+ QNetworkReply::NetworkError error, QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers)
+{
+ auto requestor = qobject_cast<AuthRequest*>(QObject::sender());
+ requestor->deleteLater();
+
+ if (error == QNetworkReply::NoError) {
+ auto doc = QJsonDocument::fromJson(data);
+ auto root = doc.object();
+ auto statusValue = root.value("status").toString("INVALID");
+ if (statusValue == "AVAILABLE") {
+ setNameStatus(NameStatus::Available);
+ } else if (statusValue == "DUPLICATE") {
+ setNameStatus(NameStatus::Exists,
+ tr("Minecraft profile with name %1 already exists.")
+ .arg(currentCheck));
+ } else if (statusValue == "NOT_ALLOWED") {
+ setNameStatus(NameStatus::Exists,
+ tr("The name %1 is not allowed.").arg(currentCheck));
+ } else {
+ setNameStatus(
+ NameStatus::Error,
+ tr("Unhandled profile name status: %1").arg(statusValue));
+ }
+ } else {
+ setNameStatus(NameStatus::Error,
+ tr("Failed to check name availability."));
+ }
+ isChecking = false;
+}
+
+void ProfileSetupDialog::setupProfile(const QString& profileName)
+{
+ if (isWorking) {
+ return;
+ }
+
+ auto token = m_accountToSetup->accessToken();
+
+ auto url = QString("https://api.minecraftservices.com/minecraft/profile");
+ QNetworkRequest request = QNetworkRequest(url);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ request.setRawHeader("Accept", "application/json");
+ request.setRawHeader("Authorization",
+ QString("Bearer %1").arg(token).toUtf8());
+
+ QString payloadTemplate("{\"profileName\":\"%1\"}");
+ auto data = payloadTemplate.arg(profileName).toUtf8();
+
+ AuthRequest* requestor = new AuthRequest(this);
+ connect(requestor, &AuthRequest::finished, this,
+ &ProfileSetupDialog::setupProfileFinished);
+ requestor->post(request, data);
+ isWorking = true;
+
+ auto button = ui->buttonBox->button(QDialogButtonBox::Cancel);
+ button->setEnabled(false);
+}
+
+namespace
+{
+
+ struct MojangError {
+ static MojangError fromJSON(QByteArray data)
+ {
+ MojangError out;
+ out.error = QString::fromUtf8(data);
+ auto doc = QJsonDocument::fromJson(data, &out.parseError);
+ auto object = doc.object();
+
+ out.fullyParsed = true;
+ out.fullyParsed &=
+ Parsers::getString(object.value("path"), out.path);
+ out.fullyParsed &=
+ Parsers::getString(object.value("error"), out.error);
+ out.fullyParsed &= Parsers::getString(object.value("errorMessage"),
+ out.errorMessage);
+
+ return out;
+ }
+
+ QString rawError;
+ QJsonParseError parseError;
+ bool fullyParsed;
+
+ QString path;
+ QString error;
+ QString errorMessage;
+ };
+
+} // namespace
+
+void ProfileSetupDialog::setupProfileFinished(
+ QNetworkReply::NetworkError error, QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers)
+{
+ auto requestor = qobject_cast<AuthRequest*>(QObject::sender());
+ requestor->deleteLater();
+
+ isWorking = false;
+ if (error == QNetworkReply::NoError) {
+ /*
+ * data contains the profile in the response
+ * ... we could parse it and update the account, but let's just return
+ * back to the normal login flow instead...
+ */
+ accept();
+ } else {
+ auto parsedError = MojangError::fromJSON(data);
+ ui->errorLabel->setVisible(true);
+ ui->errorLabel->setText(tr("The server returned the following error:") +
+ "\n\n" + parsedError.errorMessage);
+ qDebug() << parsedError.rawError;
+ auto button = ui->buttonBox->button(QDialogButtonBox::Cancel);
+ button->setEnabled(true);
+ }
+}
diff --git a/meshmc/launcher/ui/dialogs/ProfileSetupDialog.h b/meshmc/launcher/ui/dialogs/ProfileSetupDialog.h
new file mode 100644
index 0000000000..44cf3d8930
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/ProfileSetupDialog.h
@@ -0,0 +1,105 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <QIcon>
+#include <QTimer>
+#include <QNetworkReply>
+
+#include <memory>
+#include <minecraft/auth/MinecraftAccount.h>
+
+namespace Ui
+{
+ class ProfileSetupDialog;
+}
+
+class ProfileSetupDialog : public QDialog
+{
+ Q_OBJECT
+ public:
+ explicit ProfileSetupDialog(MinecraftAccountPtr accountToSetup,
+ QWidget* parent = 0);
+ ~ProfileSetupDialog();
+
+ enum class NameStatus {
+ NotSet,
+ Pending,
+ Available,
+ Exists,
+ Error
+ } nameStatus = NameStatus::NotSet;
+
+ private slots:
+ void on_buttonBox_accepted();
+ void on_buttonBox_rejected();
+
+ void nameEdited(const QString& name);
+ void checkFinished(QNetworkReply::NetworkError error, QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers);
+ void startCheck();
+
+ void setupProfileFinished(QNetworkReply::NetworkError error,
+ QByteArray data,
+ QList<QNetworkReply::RawHeaderPair> headers);
+
+ protected:
+ void scheduleCheck(const QString& name);
+ void checkName(const QString& name);
+ void setNameStatus(NameStatus status, QString errorString);
+
+ void setupProfile(const QString& profileName);
+
+ private:
+ MinecraftAccountPtr m_accountToSetup;
+ Ui::ProfileSetupDialog* ui;
+ QIcon goodIcon;
+ QIcon yellowIcon;
+ QIcon badIcon;
+ QAction* validityAction = nullptr;
+
+ QString queuedCheck;
+
+ bool isChecking = false;
+ bool isWorking = false;
+ QString currentCheck;
+
+ QTimer checkStartTimer;
+};
diff --git a/meshmc/launcher/ui/dialogs/ProfileSetupDialog.ui b/meshmc/launcher/ui/dialogs/ProfileSetupDialog.ui
new file mode 100644
index 0000000000..9dbabb4b3e
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/ProfileSetupDialog.ui
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ProfileSetupDialog</class>
+ <widget class="QDialog" name="ProfileSetupDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>615</width>
+ <height>208</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Choose Minecraft name</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="0" column="0" colspan="2">
+ <widget class="QLabel" name="descriptionLabel">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Minimum" vsizetype="Minimum">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>You just need to take one more step to be able to play Minecraft on this account.
+
+Choose your name carefully:</string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ <property name="buddy">
+ <cstring>nameEdit</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLineEdit" name="nameEdit"/>
+ </item>
+ <item row="4" column="0" colspan="2">
+ <widget class="QDialogButtonBox" name="buttonBox">
+ <property name="standardButtons">
+ <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="0">
+ <widget class="QLabel" name="errorLabel">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="text">
+ <string notr="true">Errors go here</string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ <property name="openExternalLinks">
+ <bool>true</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>nameEdit</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/ProgressDialog.cpp b/meshmc/launcher/ui/dialogs/ProgressDialog.cpp
new file mode 100644
index 0000000000..61109946bc
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/ProgressDialog.cpp
@@ -0,0 +1,201 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "ProgressDialog.h"
+#include "ui_ProgressDialog.h"
+
+#include <QKeyEvent>
+#include <QDebug>
+
+#include "tasks/Task.h"
+
+ProgressDialog::ProgressDialog(QWidget* parent)
+ : QDialog(parent), ui(new Ui::ProgressDialog)
+{
+ ui->setupUi(this);
+ this->setWindowFlags(this->windowFlags() &
+ ~Qt::WindowContextHelpButtonHint);
+ setSkipButton(false);
+ changeProgress(0, 100);
+}
+
+void ProgressDialog::setSkipButton(bool present, QString label)
+{
+ ui->skipButton->setAutoDefault(false);
+ ui->skipButton->setDefault(false);
+ ui->skipButton->setFocusPolicy(Qt::ClickFocus);
+ ui->skipButton->setEnabled(present);
+ ui->skipButton->setVisible(present);
+ ui->skipButton->setText(label);
+ updateSize();
+}
+
+void ProgressDialog::on_skipButton_clicked(bool checked)
+{
+ Q_UNUSED(checked);
+ task->abort();
+}
+
+ProgressDialog::~ProgressDialog()
+{
+ delete ui;
+}
+
+void ProgressDialog::updateSize()
+{
+ QSize qSize = QSize(480, minimumSizeHint().height());
+ resize(qSize);
+ setFixedSize(qSize);
+}
+
+int ProgressDialog::execWithTask(Task* task)
+{
+ this->task = task;
+ QDialog::DialogCode result;
+
+ if (!task) {
+ qDebug() << "Programmer error: progress dialog created with null task.";
+ return Accepted;
+ }
+
+ if (handleImmediateResult(result)) {
+ return result;
+ }
+
+ // Connect signals.
+ connect(task, SIGNAL(started()), SLOT(onTaskStarted()));
+ connect(task, SIGNAL(failed(QString)), SLOT(onTaskFailed(QString)));
+ connect(task, SIGNAL(succeeded()), SLOT(onTaskSucceeded()));
+ connect(task, SIGNAL(status(QString)), SLOT(changeStatus(const QString&)));
+ connect(task, SIGNAL(progress(qint64, qint64)),
+ SLOT(changeProgress(qint64, qint64)));
+
+ // if this didn't connect to an already running task, invoke start
+ if (!task->isRunning()) {
+ task->start();
+ }
+ if (task->isRunning()) {
+ changeProgress(task->getProgress(), task->getTotalProgress());
+ changeStatus(task->getStatus());
+ return QDialog::exec();
+ } else if (handleImmediateResult(result)) {
+ return result;
+ } else {
+ return QDialog::Rejected;
+ }
+}
+
+// TODO: only provide the unique_ptr overloads
+int ProgressDialog::execWithTask(std::unique_ptr<Task>&& task)
+{
+ connect(this, &ProgressDialog::destroyed, task.get(), &Task::deleteLater);
+ return execWithTask(task.release());
+}
+int ProgressDialog::execWithTask(std::unique_ptr<Task>& task)
+{
+ connect(this, &ProgressDialog::destroyed, task.get(), &Task::deleteLater);
+ return execWithTask(task.release());
+}
+
+bool ProgressDialog::handleImmediateResult(QDialog::DialogCode& result)
+{
+ if (task->isFinished()) {
+ if (task->wasSuccessful()) {
+ result = QDialog::Accepted;
+ } else {
+ result = QDialog::Rejected;
+ }
+ return true;
+ }
+ return false;
+}
+
+Task* ProgressDialog::getTask()
+{
+ return task;
+}
+
+void ProgressDialog::onTaskStarted() {}
+
+void ProgressDialog::onTaskFailed(QString failure)
+{
+ reject();
+}
+
+void ProgressDialog::onTaskSucceeded()
+{
+ accept();
+}
+
+void ProgressDialog::changeStatus(const QString& status)
+{
+ ui->statusLabel->setText(status);
+ updateSize();
+}
+
+void ProgressDialog::changeProgress(qint64 current, qint64 total)
+{
+ ui->taskProgressBar->setMaximum(total);
+ ui->taskProgressBar->setValue(current);
+}
+
+void ProgressDialog::keyPressEvent(QKeyEvent* e)
+{
+ if (ui->skipButton->isVisible()) {
+ if (e->key() == Qt::Key_Escape) {
+ on_skipButton_clicked(true);
+ return;
+ } else if (e->key() == Qt::Key_Tab) {
+ ui->skipButton->setFocusPolicy(Qt::StrongFocus);
+ ui->skipButton->setFocus();
+ ui->skipButton->setAutoDefault(true);
+ ui->skipButton->setDefault(true);
+ return;
+ }
+ }
+ QDialog::keyPressEvent(e);
+}
+
+void ProgressDialog::closeEvent(QCloseEvent* e)
+{
+ if (task && task->isRunning()) {
+ e->ignore();
+ } else {
+ QDialog::closeEvent(e);
+ }
+}
diff --git a/meshmc/launcher/ui/dialogs/ProgressDialog.h b/meshmc/launcher/ui/dialogs/ProgressDialog.h
new file mode 100644
index 0000000000..38d4454a26
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/ProgressDialog.h
@@ -0,0 +1,91 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <memory>
+
+class Task;
+
+namespace Ui
+{
+ class ProgressDialog;
+}
+
+class ProgressDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit ProgressDialog(QWidget* parent = 0);
+ ~ProgressDialog();
+
+ void updateSize();
+
+ int execWithTask(Task* task);
+ int execWithTask(std::unique_ptr<Task>&& task);
+ int execWithTask(std::unique_ptr<Task>& task);
+
+ void setSkipButton(bool present, QString label = QString());
+
+ Task* getTask();
+
+ public slots:
+ void onTaskStarted();
+ void onTaskFailed(QString failure);
+ void onTaskSucceeded();
+
+ void changeStatus(const QString& status);
+ void changeProgress(qint64 current, qint64 total);
+
+ private slots:
+ void on_skipButton_clicked(bool checked);
+
+ protected:
+ virtual void keyPressEvent(QKeyEvent* e);
+ virtual void closeEvent(QCloseEvent* e);
+
+ private:
+ bool handleImmediateResult(QDialog::DialogCode& result);
+
+ private:
+ Ui::ProgressDialog* ui;
+
+ Task* task;
+};
diff --git a/meshmc/launcher/ui/dialogs/ProgressDialog.ui b/meshmc/launcher/ui/dialogs/ProgressDialog.ui
new file mode 100644
index 0000000000..04b8fef33a
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/ProgressDialog.ui
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ProgressDialog</class>
+ <widget class="QDialog" name="ProgressDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>400</width>
+ <height>100</height>
+ </rect>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>400</width>
+ <height>0</height>
+ </size>
+ </property>
+ <property name="maximumSize">
+ <size>
+ <width>600</width>
+ <height>16777215</height>
+ </size>
+ </property>
+ <property name="windowTitle">
+ <string>Please wait...</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="0" column="0">
+ <widget class="QLabel" name="statusLabel">
+ <property name="text">
+ <string>Task Status...</string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QProgressBar" name="taskProgressBar">
+ <property name="value">
+ <number>24</number>
+ </property>
+ <property name="textVisible">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
+ <widget class="QPushButton" name="skipButton">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>Skip</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/SkinUploadDialog.cpp b/meshmc/launcher/ui/dialogs/SkinUploadDialog.cpp
new file mode 100644
index 0000000000..414e0acaf0
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/SkinUploadDialog.cpp
@@ -0,0 +1,163 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <QFileInfo>
+#include <QFileDialog>
+#include <QPainter>
+#include <QRegularExpression>
+
+#include <FileSystem.h>
+
+#include <minecraft/services/SkinUpload.h>
+#include <minecraft/services/CapeChange.h>
+#include <tasks/SequentialTask.h>
+
+#include "SkinUploadDialog.h"
+#include "ui_SkinUploadDialog.h"
+#include "ProgressDialog.h"
+#include "CustomMessageBox.h"
+
+void SkinUploadDialog::on_buttonBox_rejected()
+{
+ close();
+}
+
+void SkinUploadDialog::on_buttonBox_accepted()
+{
+ QString fileName;
+ QString input = ui->skinPathTextBox->text();
+ QRegularExpression urlPrefixMatcher("^([a-z]+)://.+$");
+ bool isLocalFile = false;
+ // it has an URL prefix -> it is an URL
+ if (urlPrefixMatcher.match(input).hasMatch()) {
+ QUrl fileURL = input;
+ if (fileURL.isValid()) {
+ // local?
+ if (fileURL.isLocalFile()) {
+ isLocalFile = true;
+ fileName = fileURL.toLocalFile();
+ } else {
+ CustomMessageBox::selectable(
+ this, tr("Skin Upload"),
+ tr("Using remote URLs for setting skins is not implemented "
+ "yet."),
+ QMessageBox::Warning)
+ ->exec();
+ close();
+ return;
+ }
+ } else {
+ CustomMessageBox::selectable(
+ this, tr("Skin Upload"),
+ tr("You cannot use an invalid URL for uploading skins."),
+ QMessageBox::Warning)
+ ->exec();
+ close();
+ return;
+ }
+ } else {
+ // just assume it's a path then
+ isLocalFile = true;
+ fileName = ui->skinPathTextBox->text();
+ }
+ if (isLocalFile && !QFile::exists(fileName)) {
+ CustomMessageBox::selectable(this, tr("Skin Upload"),
+ tr("Skin file does not exist!"),
+ QMessageBox::Warning)
+ ->exec();
+ close();
+ return;
+ }
+ SkinUpload::Model model = SkinUpload::STEVE;
+ if (ui->steveBtn->isChecked()) {
+ model = SkinUpload::STEVE;
+ } else if (ui->alexBtn->isChecked()) {
+ model = SkinUpload::ALEX;
+ }
+ ProgressDialog prog(this);
+ SequentialTask skinUpload;
+ skinUpload.addTask(shared_qobject_ptr<SkinUpload>(new SkinUpload(
+ this, m_acct->accessToken(), FS::read(fileName), model)));
+ auto selectedCape = ui->capeCombo->currentData().toString();
+ if (selectedCape != m_acct->accountData()->minecraftProfile.currentCape) {
+ skinUpload.addTask(shared_qobject_ptr<CapeChange>(
+ new CapeChange(this, m_acct->accessToken(), selectedCape)));
+ }
+ if (prog.execWithTask(&skinUpload) != QDialog::Accepted) {
+ CustomMessageBox::selectable(this, tr("Skin Upload"),
+ tr("Failed to upload skin!"),
+ QMessageBox::Warning)
+ ->exec();
+ close();
+ return;
+ }
+ CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Success"),
+ QMessageBox::Information)
+ ->exec();
+ close();
+}
+
+void SkinUploadDialog::on_skinBrowseBtn_clicked()
+{
+ QString raw_path = QFileDialog::getOpenFileName(
+ this, tr("Select Skin Texture"), QString(), "*.png");
+ if (raw_path.isEmpty() || !QFileInfo::exists(raw_path)) {
+ return;
+ }
+ QString cooked_path = FS::NormalizePath(raw_path);
+ ui->skinPathTextBox->setText(cooked_path);
+}
+
+SkinUploadDialog::SkinUploadDialog(MinecraftAccountPtr acct, QWidget* parent)
+ : QDialog(parent), m_acct(acct), ui(new Ui::SkinUploadDialog)
+{
+ ui->setupUi(this);
+
+ // FIXME: add a model for this, download/refresh the capes on demand
+ auto& data = *acct->accountData();
+ int index = 0;
+ ui->capeCombo->addItem(tr("No Cape"), QVariant());
+ auto currentCape = data.minecraftProfile.currentCape;
+ if (currentCape.isEmpty()) {
+ ui->capeCombo->setCurrentIndex(index);
+ }
+
+ for (auto& cape : data.minecraftProfile.capes) {
+ index++;
+ if (cape.data.size()) {
+ QPixmap capeImage;
+ if (capeImage.loadFromData(cape.data, "PNG")) {
+ QPixmap preview = QPixmap(10, 16);
+ QPainter painter(&preview);
+ painter.drawPixmap(0, 0, capeImage.copy(1, 1, 10, 16));
+ ui->capeCombo->addItem(capeImage, cape.alias, cape.id);
+ if (currentCape == cape.id) {
+ ui->capeCombo->setCurrentIndex(index);
+ }
+ continue;
+ }
+ }
+ ui->capeCombo->addItem(cape.alias, cape.id);
+ if (currentCape == cape.id) {
+ ui->capeCombo->setCurrentIndex(index);
+ }
+ }
+}
diff --git a/meshmc/launcher/ui/dialogs/SkinUploadDialog.h b/meshmc/launcher/ui/dialogs/SkinUploadDialog.h
new file mode 100644
index 0000000000..3c9b7aceb2
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/SkinUploadDialog.h
@@ -0,0 +1,51 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <minecraft/auth/MinecraftAccount.h>
+
+namespace Ui
+{
+ class SkinUploadDialog;
+}
+
+class SkinUploadDialog : public QDialog
+{
+ Q_OBJECT
+ public:
+ explicit SkinUploadDialog(MinecraftAccountPtr acct, QWidget* parent = 0);
+ virtual ~SkinUploadDialog() {};
+
+ public slots:
+ void on_buttonBox_accepted();
+
+ void on_buttonBox_rejected();
+
+ void on_skinBrowseBtn_clicked();
+
+ protected:
+ MinecraftAccountPtr m_acct;
+
+ private:
+ Ui::SkinUploadDialog* ui;
+};
diff --git a/meshmc/launcher/ui/dialogs/SkinUploadDialog.ui b/meshmc/launcher/ui/dialogs/SkinUploadDialog.ui
new file mode 100644
index 0000000000..f4b0ed0aa7
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/SkinUploadDialog.ui
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>SkinUploadDialog</class>
+ <widget class="QDialog" name="SkinUploadDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>394</width>
+ <height>360</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Skin Upload</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QGroupBox" name="fileBox">
+ <property name="title">
+ <string>Skin File</string>
+ </property>
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <item>
+ <widget class="QLineEdit" name="skinPathTextBox"/>
+ </item>
+ <item>
+ <widget class="QPushButton" name="skinBrowseBtn">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="maximumSize">
+ <size>
+ <width>28</width>
+ <height>16777215</height>
+ </size>
+ </property>
+ <property name="text">
+ <string notr="true">...</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="modelBox">
+ <property name="title">
+ <string>Player Model</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_1">
+ <item>
+ <widget class="QRadioButton" name="steveBtn">
+ <property name="text">
+ <string>Steve Model</string>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QRadioButton" name="alexBtn">
+ <property name="text">
+ <string>Alex Model</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="capeBox">
+ <property name="title">
+ <string>Cape</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <item>
+ <widget class="QComboBox" name="capeCombo"/>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QDialogButtonBox" name="buttonBox">
+ <property name="standardButtons">
+ <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/UpdateDialog.cpp b/meshmc/launcher/ui/dialogs/UpdateDialog.cpp
new file mode 100644
index 0000000000..74838fc8be
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/UpdateDialog.cpp
@@ -0,0 +1,77 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "UpdateDialog.h"
+#include "ui_UpdateDialog.h"
+#include "Application.h"
+#include "BuildConfig.h"
+
+UpdateDialog::UpdateDialog(bool hasUpdate, const UpdateAvailableStatus& status,
+ QWidget* parent)
+ : QDialog(parent), ui(new Ui::UpdateDialog)
+{
+ ui->setupUi(this);
+
+ if (hasUpdate) {
+ ui->label->setText(
+ tr("<b>%1 %2</b> is available!")
+ .arg(BuildConfig.MESHMC_DISPLAYNAME, status.version));
+
+ if (!status.releaseNotes.isEmpty()) {
+ ui->changelogBrowser->setHtml(status.releaseNotes);
+ } else {
+ ui->changelogBrowser->setHtml(
+ tr("<center><p>No release notes available.</p></center>"));
+ }
+ } else {
+ ui->label->setText(tr("You are running the latest version of %1.")
+ .arg(BuildConfig.MESHMC_DISPLAYNAME));
+ ui->changelogBrowser->setHtml(
+ tr("<center><p>No updates found.</p></center>"));
+ ui->btnUpdateNow->setHidden(true);
+ ui->btnUpdateLater->setText(tr("Close"));
+ }
+
+ restoreGeometry(QByteArray::fromBase64(
+ APPLICATION->settings()->get("UpdateDialogGeometry").toByteArray()));
+}
+
+UpdateDialog::~UpdateDialog()
+{
+ delete ui;
+}
+
+void UpdateDialog::on_btnUpdateLater_clicked()
+{
+ reject();
+}
+
+void UpdateDialog::on_btnUpdateNow_clicked()
+{
+ done(UPDATE_NOW);
+}
+
+void UpdateDialog::closeEvent(QCloseEvent* evt)
+{
+ APPLICATION->settings()->set("UpdateDialogGeometry",
+ saveGeometry().toBase64());
+ QDialog::closeEvent(evt);
+}
diff --git a/meshmc/launcher/ui/dialogs/UpdateDialog.h b/meshmc/launcher/ui/dialogs/UpdateDialog.h
new file mode 100644
index 0000000000..1bc1775710
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/UpdateDialog.h
@@ -0,0 +1,80 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include "updater/UpdateChecker.h"
+
+namespace Ui
+{
+ class UpdateDialog;
+}
+
+enum UpdateAction {
+ UPDATE_LATER = QDialog::Rejected,
+ UPDATE_NOW = QDialog::Accepted,
+};
+
+class UpdateDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ /*!
+ * Constructs the update dialog.
+ * \a hasUpdate - true when an update is available (shows "Update now"
+ * button).
+ * \a status - update information (version, release notes); ignored
+ * when hasUpdate is false.
+ */
+ explicit UpdateDialog(bool hasUpdate,
+ const UpdateAvailableStatus& status = {},
+ QWidget* parent = nullptr);
+ ~UpdateDialog();
+
+ public slots:
+ void on_btnUpdateNow_clicked();
+ void on_btnUpdateLater_clicked();
+
+ protected:
+ void closeEvent(QCloseEvent*) override;
+
+ private:
+ Ui::UpdateDialog* ui;
+};
diff --git a/meshmc/launcher/ui/dialogs/UpdateDialog.ui b/meshmc/launcher/ui/dialogs/UpdateDialog.ui
new file mode 100644
index 0000000000..ed895883c9
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/UpdateDialog.ui
@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>UpdateDialog</class>
+ <widget class="QDialog" name="UpdateDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>657</width>
+ <height>673</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>MeshMC Update</string>
+ </property>
+ <property name="windowIcon">
+ <iconset>
+ <normaloff>:/icons/toolbar/checkupdate</normaloff>:/icons/toolbar/checkupdate</iconset>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_2">
+ <item>
+ <widget class="QLabel" name="label">
+ <property name="font">
+ <font>
+ <pointsize>14</pointsize>
+ </font>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
+ </property>
+ <property name="buddy">
+ <cstring>changelogBrowser</cstring>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QTextBrowser" name="changelogBrowser">
+ <property name="openExternalLinks">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="0" column="0">
+ <widget class="QPushButton" name="btnUpdateNow">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>Update now</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QPushButton" name="btnUpdateLater">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>Don't update yet</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>changelogBrowser</tabstop>
+ <tabstop>btnUpdateNow</tabstop>
+ <tabstop>btnUpdateLater</tabstop>
+ </tabstops>
+ <resources>
+ <include location="../../resources/multimc/multimc.qrc"/>
+ </resources>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/dialogs/VersionSelectDialog.cpp b/meshmc/launcher/ui/dialogs/VersionSelectDialog.cpp
new file mode 100644
index 0000000000..d8ef9b1245
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/VersionSelectDialog.cpp
@@ -0,0 +1,166 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "VersionSelectDialog.h"
+
+#include <QtWidgets/QButtonGroup>
+#include <QtWidgets/QDialogButtonBox>
+#include <QtWidgets/QHBoxLayout>
+#include <QtWidgets/QPushButton>
+#include <QtWidgets/QVBoxLayout>
+#include <QDebug>
+
+#include "ui/dialogs/ProgressDialog.h"
+#include "ui/widgets/VersionSelectWidget.h"
+#include "ui/dialogs/CustomMessageBox.h"
+
+#include "BaseVersion.h"
+#include "BaseVersionList.h"
+#include "tasks/Task.h"
+#include "Application.h"
+#include "VersionProxyModel.h"
+
+VersionSelectDialog::VersionSelectDialog(BaseVersionList* vlist, QString title,
+ QWidget* parent, bool cancelable)
+ : QDialog(parent)
+{
+ setObjectName(QStringLiteral("VersionSelectDialog"));
+ resize(400, 347);
+ m_verticalLayout = new QVBoxLayout(this);
+ m_verticalLayout->setObjectName(QStringLiteral("verticalLayout"));
+
+ m_versionWidget = new VersionSelectWidget(parent);
+ m_verticalLayout->addWidget(m_versionWidget);
+
+ m_horizontalLayout = new QHBoxLayout();
+ m_horizontalLayout->setObjectName(QStringLiteral("horizontalLayout"));
+
+ m_refreshButton = new QPushButton(this);
+ m_refreshButton->setObjectName(QStringLiteral("refreshButton"));
+ m_horizontalLayout->addWidget(m_refreshButton);
+
+ m_buttonBox = new QDialogButtonBox(this);
+ m_buttonBox->setObjectName(QStringLiteral("buttonBox"));
+ m_buttonBox->setOrientation(Qt::Horizontal);
+ m_buttonBox->setStandardButtons(QDialogButtonBox::Cancel |
+ QDialogButtonBox::Ok);
+ m_horizontalLayout->addWidget(m_buttonBox);
+
+ m_verticalLayout->addLayout(m_horizontalLayout);
+
+ retranslate();
+
+ QObject::connect(m_buttonBox, SIGNAL(accepted()), this, SLOT(accept()));
+ QObject::connect(m_buttonBox, SIGNAL(rejected()), this, SLOT(reject()));
+
+ QMetaObject::connectSlotsByName(this);
+ setWindowModality(Qt::WindowModal);
+ setWindowTitle(title);
+
+ m_vlist = vlist;
+
+ if (!cancelable) {
+ m_buttonBox->button(QDialogButtonBox::Cancel)->setEnabled(false);
+ }
+}
+
+void VersionSelectDialog::retranslate()
+{
+ // FIXME: overrides custom title given in constructor!
+ setWindowTitle(tr("Choose Version"));
+ m_refreshButton->setToolTip(tr("Reloads the version list."));
+ m_refreshButton->setText(tr("&Refresh"));
+}
+
+void VersionSelectDialog::setCurrentVersion(const QString& version)
+{
+ m_currentVersion = version;
+ m_versionWidget->setCurrentVersion(version);
+}
+
+void VersionSelectDialog::setEmptyString(QString emptyString)
+{
+ m_versionWidget->setEmptyString(emptyString);
+}
+
+void VersionSelectDialog::setEmptyErrorString(QString emptyErrorString)
+{
+ m_versionWidget->setEmptyErrorString(emptyErrorString);
+}
+
+void VersionSelectDialog::setResizeOn(int column)
+{
+ resizeOnColumn = column;
+}
+
+int VersionSelectDialog::exec()
+{
+ QDialog::open();
+ m_versionWidget->initialize(m_vlist);
+ if (resizeOnColumn != -1) {
+ m_versionWidget->setResizeOn(resizeOnColumn);
+ }
+ return QDialog::exec();
+}
+
+void VersionSelectDialog::selectRecommended()
+{
+ m_versionWidget->selectRecommended();
+}
+
+BaseVersionPtr VersionSelectDialog::selectedVersion() const
+{
+ return m_versionWidget->selectedVersion();
+}
+
+void VersionSelectDialog::on_refreshButton_clicked()
+{
+ m_versionWidget->loadList();
+}
+
+void VersionSelectDialog::setExactFilter(BaseVersionList::ModelRoles role,
+ QString filter)
+{
+ m_versionWidget->setExactFilter(role, filter);
+}
+
+void VersionSelectDialog::setFuzzyFilter(BaseVersionList::ModelRoles role,
+ QString filter)
+{
+ m_versionWidget->setFuzzyFilter(role, filter);
+}
diff --git a/meshmc/launcher/ui/dialogs/VersionSelectDialog.h b/meshmc/launcher/ui/dialogs/VersionSelectDialog.h
new file mode 100644
index 0000000000..8db01cc62c
--- /dev/null
+++ b/meshmc/launcher/ui/dialogs/VersionSelectDialog.h
@@ -0,0 +1,101 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <QSortFilterProxyModel>
+
+#include "BaseVersionList.h"
+
+class QVBoxLayout;
+class QHBoxLayout;
+class QDialogButtonBox;
+class VersionSelectWidget;
+class QPushButton;
+
+namespace Ui
+{
+ class VersionSelectDialog;
+}
+
+class VersionProxyModel;
+
+class VersionSelectDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ explicit VersionSelectDialog(BaseVersionList* vlist, QString title,
+ QWidget* parent = 0, bool cancelable = true);
+ virtual ~VersionSelectDialog() {};
+
+ int exec() override;
+
+ BaseVersionPtr selectedVersion() const;
+
+ void setCurrentVersion(const QString& version);
+ void setFuzzyFilter(BaseVersionList::ModelRoles role, QString filter);
+ void setExactFilter(BaseVersionList::ModelRoles role, QString filter);
+ void setEmptyString(QString emptyString);
+ void setEmptyErrorString(QString emptyErrorString);
+ void setResizeOn(int column);
+
+ private slots:
+ void on_refreshButton_clicked();
+
+ private:
+ void retranslate();
+ void selectRecommended();
+
+ private:
+ QString m_currentVersion;
+ VersionSelectWidget* m_versionWidget = nullptr;
+ QVBoxLayout* m_verticalLayout = nullptr;
+ QHBoxLayout* m_horizontalLayout = nullptr;
+ QPushButton* m_refreshButton = nullptr;
+ QDialogButtonBox* m_buttonBox = nullptr;
+
+ BaseVersionList* m_vlist = nullptr;
+
+ VersionProxyModel* m_proxyModel = nullptr;
+
+ int resizeOnColumn = -1;
+
+ Task* loadTask = nullptr;
+};
diff --git a/meshmc/launcher/ui/instanceview/AccessibleInstanceView.cpp b/meshmc/launcher/ui/instanceview/AccessibleInstanceView.cpp
new file mode 100644
index 0000000000..af45a3fb68
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/AccessibleInstanceView.cpp
@@ -0,0 +1,859 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "InstanceView.h"
+#include "AccessibleInstanceView.h"
+#include "AccessibleInstanceView_p.h"
+
+#include <qvariant.h>
+#include <qaccessible.h>
+#include <qheaderview.h>
+
+#ifndef QT_NO_ACCESSIBILITY
+
+QAccessibleInterface* groupViewAccessibleFactory(const QString& classname,
+ QObject* object)
+{
+ QAccessibleInterface* iface = 0;
+ if (!object || !object->isWidgetType())
+ return iface;
+
+ QWidget* widget = static_cast<QWidget*>(object);
+
+ if (classname == QLatin1String("InstanceView")) {
+ iface = new AccessibleInstanceView((InstanceView*)widget);
+ }
+ return iface;
+}
+
+QAbstractItemView* AccessibleInstanceView::view() const
+{
+ return qobject_cast<QAbstractItemView*>(object());
+}
+
+int AccessibleInstanceView::logicalIndex(const QModelIndex& index) const
+{
+ if (!view()->model() || !index.isValid())
+ return -1;
+ return index.row() * (index.model()->columnCount()) + index.column();
+}
+
+AccessibleInstanceView::AccessibleInstanceView(QWidget* w)
+ : QAccessibleObject(w)
+{
+ Q_ASSERT(view());
+}
+
+bool AccessibleInstanceView::isValid() const
+{
+ return view();
+}
+
+AccessibleInstanceView::~AccessibleInstanceView()
+{
+ for (QAccessible::Id id : childToId) {
+ QAccessible::deleteAccessibleInterface(id);
+ }
+}
+
+QAccessibleInterface* AccessibleInstanceView::cellAt(int row, int column) const
+{
+ if (!view()->model()) {
+ return 0;
+ }
+
+ QModelIndex index =
+ view()->model()->index(row, column, view()->rootIndex());
+ if (Q_UNLIKELY(!index.isValid())) {
+ qWarning() << "AccessibleInstanceView::cellAt: invalid index: " << index
+ << " for " << view();
+ return 0;
+ }
+
+ return child(logicalIndex(index));
+}
+
+QAccessibleInterface* AccessibleInstanceView::caption() const
+{
+ return 0;
+}
+
+QString AccessibleInstanceView::columnDescription(int column) const
+{
+ if (!view()->model())
+ return QString();
+
+ return view()->model()->headerData(column, Qt::Horizontal).toString();
+}
+
+int AccessibleInstanceView::columnCount() const
+{
+ if (!view()->model())
+ return 0;
+ return 1;
+}
+
+int AccessibleInstanceView::rowCount() const
+{
+ if (!view()->model())
+ return 0;
+ return view()->model()->rowCount();
+}
+
+int AccessibleInstanceView::selectedCellCount() const
+{
+ if (!view()->selectionModel())
+ return 0;
+ return view()->selectionModel()->selectedIndexes().count();
+}
+
+int AccessibleInstanceView::selectedColumnCount() const
+{
+ if (!view()->selectionModel())
+ return 0;
+ return view()->selectionModel()->selectedColumns().count();
+}
+
+int AccessibleInstanceView::selectedRowCount() const
+{
+ if (!view()->selectionModel())
+ return 0;
+ return view()->selectionModel()->selectedRows().count();
+}
+
+QString AccessibleInstanceView::rowDescription(int row) const
+{
+ if (!view()->model())
+ return QString();
+ return view()->model()->headerData(row, Qt::Vertical).toString();
+}
+
+QList<QAccessibleInterface*> AccessibleInstanceView::selectedCells() const
+{
+ QList<QAccessibleInterface*> cells;
+ if (!view()->selectionModel())
+ return cells;
+ const QModelIndexList selectedIndexes =
+ view()->selectionModel()->selectedIndexes();
+ cells.reserve(selectedIndexes.size());
+ for (const QModelIndex& index : selectedIndexes)
+ cells.append(child(logicalIndex(index)));
+ return cells;
+}
+
+QList<int> AccessibleInstanceView::selectedColumns() const
+{
+ if (!view()->selectionModel()) {
+ return QList<int>();
+ }
+
+ const QModelIndexList selectedColumns =
+ view()->selectionModel()->selectedColumns();
+
+ QList<int> columns;
+ columns.reserve(selectedColumns.size());
+ for (const QModelIndex& index : selectedColumns) {
+ columns.append(index.column());
+ }
+
+ return columns;
+}
+
+QList<int> AccessibleInstanceView::selectedRows() const
+{
+ if (!view()->selectionModel()) {
+ return QList<int>();
+ }
+
+ QList<int> rows;
+
+ const QModelIndexList selectedRows =
+ view()->selectionModel()->selectedRows();
+
+ rows.reserve(selectedRows.size());
+ for (const QModelIndex& index : selectedRows) {
+ rows.append(index.row());
+ }
+
+ return rows;
+}
+
+QAccessibleInterface* AccessibleInstanceView::summary() const
+{
+ return 0;
+}
+
+bool AccessibleInstanceView::isColumnSelected(int column) const
+{
+ if (!view()->selectionModel()) {
+ return false;
+ }
+
+ return view()->selectionModel()->isColumnSelected(column, QModelIndex());
+}
+
+bool AccessibleInstanceView::isRowSelected(int row) const
+{
+ if (!view()->selectionModel()) {
+ return false;
+ }
+
+ return view()->selectionModel()->isRowSelected(row, QModelIndex());
+}
+
+bool AccessibleInstanceView::selectRow(int row)
+{
+ if (!view()->model() || !view()->selectionModel()) {
+ return false;
+ }
+ QModelIndex index = view()->model()->index(row, 0, view()->rootIndex());
+
+ if (!index.isValid() ||
+ view()->selectionBehavior() == QAbstractItemView::SelectColumns) {
+ return false;
+ }
+
+ switch (view()->selectionMode()) {
+ case QAbstractItemView::NoSelection: {
+ return false;
+ }
+ case QAbstractItemView::SingleSelection: {
+ if (view()->selectionBehavior() != QAbstractItemView::SelectRows &&
+ columnCount() > 1)
+ return false;
+ view()->clearSelection();
+ break;
+ }
+ case QAbstractItemView::ContiguousSelection: {
+ if ((!row || !view()->selectionModel()->isRowSelected(
+ row - 1, view()->rootIndex())) &&
+ !view()->selectionModel()->isRowSelected(row + 1,
+ view()->rootIndex())) {
+ view()->clearSelection();
+ }
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+
+ view()->selectionModel()->select(index, QItemSelectionModel::Select |
+ QItemSelectionModel::Rows);
+ return true;
+}
+
+bool AccessibleInstanceView::selectColumn(int column)
+{
+ if (!view()->model() || !view()->selectionModel()) {
+ return false;
+ }
+ QModelIndex index = view()->model()->index(0, column, view()->rootIndex());
+
+ if (!index.isValid() ||
+ view()->selectionBehavior() == QAbstractItemView::SelectRows) {
+ return false;
+ }
+
+ switch (view()->selectionMode()) {
+ case QAbstractItemView::NoSelection: {
+ return false;
+ }
+ case QAbstractItemView::SingleSelection: {
+ if (view()->selectionBehavior() !=
+ QAbstractItemView::SelectColumns &&
+ rowCount() > 1) {
+ return false;
+ }
+ // fallthrough intentional
+ }
+ case QAbstractItemView::ContiguousSelection: {
+ if ((!column || !view()->selectionModel()->isColumnSelected(
+ column - 1, view()->rootIndex())) &&
+ !view()->selectionModel()->isColumnSelected(
+ column + 1, view()->rootIndex())) {
+ view()->clearSelection();
+ }
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+
+ view()->selectionModel()->select(index, QItemSelectionModel::Select |
+ QItemSelectionModel::Columns);
+ return true;
+}
+
+bool AccessibleInstanceView::unselectRow(int row)
+{
+ if (!view()->model() || !view()->selectionModel()) {
+ return false;
+ }
+
+ QModelIndex index = view()->model()->index(row, 0, view()->rootIndex());
+ if (!index.isValid()) {
+ return false;
+ }
+
+ QItemSelection selection(index, index);
+ auto selectionModel = view()->selectionModel();
+
+ switch (view()->selectionMode()) {
+ case QAbstractItemView::SingleSelection:
+ // no unselect
+ if (selectedRowCount() == 1) {
+ return false;
+ }
+ break;
+ case QAbstractItemView::ContiguousSelection: {
+ // no unselect
+ if (selectedRowCount() == 1) {
+ return false;
+ }
+
+ if ((!row ||
+ selectionModel->isRowSelected(row - 1, view()->rootIndex())) &&
+ selectionModel->isRowSelected(row + 1, view()->rootIndex())) {
+ // If there are rows selected both up the current row and down
+ // the current rown, the ones which are down the current row
+ // will be deselected
+ selection = QItemSelection(
+ index, view()->model()->index(rowCount() - 1, 0,
+ view()->rootIndex()));
+ }
+ }
+ default: {
+ break;
+ }
+ }
+
+ selectionModel->select(selection, QItemSelectionModel::Deselect |
+ QItemSelectionModel::Rows);
+ return true;
+}
+
+bool AccessibleInstanceView::unselectColumn(int column)
+{
+ auto model = view()->model();
+ if (!model || !view()->selectionModel()) {
+ return false;
+ }
+
+ QModelIndex index = model->index(0, column, view()->rootIndex());
+ if (!index.isValid()) {
+ return false;
+ }
+
+ QItemSelection selection(index, index);
+
+ switch (view()->selectionMode()) {
+ case QAbstractItemView::SingleSelection: {
+ // In SingleSelection and ContiguousSelection once an item
+ // is selected, there's no way for the user to unselect all items
+ if (selectedColumnCount() == 1) {
+ return false;
+ }
+ break;
+ }
+ case QAbstractItemView::ContiguousSelection:
+ if (selectedColumnCount() == 1) {
+ return false;
+ }
+
+ if ((!column || view()->selectionModel()->isColumnSelected(
+ column - 1, view()->rootIndex())) &&
+ view()->selectionModel()->isColumnSelected(
+ column + 1, view()->rootIndex())) {
+ // If there are columns selected both at the left of the current
+ // row and at the right of the current row, the ones which are
+ // at the right will be deselected
+ selection =
+ QItemSelection(index, model->index(0, columnCount() - 1,
+ view()->rootIndex()));
+ }
+ default:
+ break;
+ }
+
+ view()->selectionModel()->select(selection,
+ QItemSelectionModel::Deselect |
+ QItemSelectionModel::Columns);
+ return true;
+}
+
+QAccessible::Role AccessibleInstanceView::role() const
+{
+ return QAccessible::List;
+}
+
+QAccessible::State AccessibleInstanceView::state() const
+{
+ return QAccessible::State();
+}
+
+QAccessibleInterface* AccessibleInstanceView::childAt(int x, int y) const
+{
+ QPoint viewportOffset = view()->viewport()->mapTo(view(), QPoint(0, 0));
+ QPoint indexPosition = view()->mapFromGlobal(QPoint(x, y) - viewportOffset);
+ // FIXME: if indexPosition < 0 in one coordinate, return header
+
+ QModelIndex index = view()->indexAt(indexPosition);
+ if (index.isValid()) {
+ return child(logicalIndex(index));
+ }
+ return 0;
+}
+
+int AccessibleInstanceView::childCount() const
+{
+ if (!view()->model()) {
+ return 0;
+ }
+ return (view()->model()->rowCount()) * (view()->model()->columnCount());
+}
+
+int AccessibleInstanceView::indexOfChild(
+ const QAccessibleInterface* iface) const
+{
+ if (!view()->model())
+ return -1;
+ QAccessibleInterface* parent = iface->parent();
+ if (parent->object() != view())
+ return -1;
+
+ Q_ASSERT(iface->role() !=
+ QAccessible::TreeItem); // should be handled by tree class
+ if (iface->role() == QAccessible::Cell ||
+ iface->role() == QAccessible::ListItem) {
+ const AccessibleInstanceViewItem* cell =
+ static_cast<const AccessibleInstanceViewItem*>(iface);
+ return logicalIndex(cell->m_index);
+ } else if (iface->role() == QAccessible::Pane) {
+ return 0; // corner button
+ } else {
+ qWarning() << "AccessibleInstanceView::indexOfChild has a child with "
+ "unknown role..."
+ << iface->role() << iface->text(QAccessible::Name);
+ }
+ // FIXME: we are in denial of our children. this should stop.
+ return -1;
+}
+
+QString AccessibleInstanceView::text(QAccessible::Text t) const
+{
+ if (t == QAccessible::Description)
+ return view()->accessibleDescription();
+ return view()->accessibleName();
+}
+
+QRect AccessibleInstanceView::rect() const
+{
+ if (!view()->isVisible())
+ return QRect();
+ QPoint pos = view()->mapToGlobal(QPoint(0, 0));
+ return QRect(pos.x(), pos.y(), view()->width(), view()->height());
+}
+
+QAccessibleInterface* AccessibleInstanceView::parent() const
+{
+ if (view() && view()->parent()) {
+ if (qstrcmp("QComboBoxPrivateContainer",
+ view()->parent()->metaObject()->className()) == 0) {
+ return QAccessible::queryAccessibleInterface(
+ view()->parent()->parent());
+ }
+ return QAccessible::queryAccessibleInterface(view()->parent());
+ }
+ return 0;
+}
+
+QAccessibleInterface* AccessibleInstanceView::child(int logicalIndex) const
+{
+ if (!view()->model())
+ return 0;
+
+ auto id = childToId.constFind(logicalIndex);
+ if (id != childToId.constEnd())
+ return QAccessible::accessibleInterface(id.value());
+
+ int columns = view()->model()->columnCount();
+
+ int row = logicalIndex / columns;
+ int column = logicalIndex % columns;
+
+ QAccessibleInterface* iface = 0;
+
+ QModelIndex index =
+ view()->model()->index(row, column, view()->rootIndex());
+ if (Q_UNLIKELY(!index.isValid())) {
+ qWarning("AccessibleInstanceView::child: Invalid index at: %d %d", row,
+ column);
+ return 0;
+ }
+ iface = new AccessibleInstanceViewItem(view(), index);
+
+ QAccessible::registerAccessibleInterface(iface);
+ childToId.insert(logicalIndex, QAccessible::uniqueId(iface));
+ return iface;
+}
+
+void* AccessibleInstanceView::interface_cast(QAccessible::InterfaceType t)
+{
+ if (t == QAccessible::TableInterface)
+ return static_cast<QAccessibleTableInterface*>(this);
+ return 0;
+}
+
+void AccessibleInstanceView::modelChange(
+ QAccessibleTableModelChangeEvent* event)
+{
+ // if there is no cache yet, we don't update anything
+ if (childToId.isEmpty())
+ return;
+
+ switch (event->modelChangeType()) {
+ case QAccessibleTableModelChangeEvent::ModelReset:
+ for (QAccessible::Id id : childToId)
+ QAccessible::deleteAccessibleInterface(id);
+ childToId.clear();
+ break;
+
+ // rows are inserted: move every row after that
+ case QAccessibleTableModelChangeEvent::RowsInserted:
+ case QAccessibleTableModelChangeEvent::ColumnsInserted: {
+
+ ChildCache newCache;
+ ChildCache::ConstIterator iter = childToId.constBegin();
+
+ while (iter != childToId.constEnd()) {
+ QAccessible::Id id = iter.value();
+ QAccessibleInterface* iface =
+ QAccessible::accessibleInterface(id);
+ Q_ASSERT(iface);
+ if (indexOfChild(iface) >= 0) {
+ newCache.insert(indexOfChild(iface), id);
+ } else {
+ // ### This should really not happen,
+ // but it might if the view has a root index set.
+ // This needs to be fixed.
+ QAccessible::deleteAccessibleInterface(id);
+ }
+ ++iter;
+ }
+ childToId = newCache;
+ break;
+ }
+
+ case QAccessibleTableModelChangeEvent::ColumnsRemoved:
+ case QAccessibleTableModelChangeEvent::RowsRemoved: {
+ ChildCache newCache;
+ ChildCache::ConstIterator iter = childToId.constBegin();
+ while (iter != childToId.constEnd()) {
+ QAccessible::Id id = iter.value();
+ QAccessibleInterface* iface =
+ QAccessible::accessibleInterface(id);
+ Q_ASSERT(iface);
+ if (iface->role() == QAccessible::Cell ||
+ iface->role() == QAccessible::ListItem) {
+ Q_ASSERT(iface->tableCellInterface());
+ AccessibleInstanceViewItem* cell =
+ static_cast<AccessibleInstanceViewItem*>(
+ iface->tableCellInterface());
+ // Since it is a QPersistentModelIndex, we only need to
+ // check if it is valid
+ if (cell->m_index.isValid())
+ newCache.insert(indexOfChild(cell), id);
+ else
+ QAccessible::deleteAccessibleInterface(id);
+ }
+ ++iter;
+ }
+ childToId = newCache;
+ break;
+ }
+
+ case QAccessibleTableModelChangeEvent::DataChanged:
+ // nothing to do in this case
+ break;
+ }
+}
+
+// TABLE CELL
+
+AccessibleInstanceViewItem::AccessibleInstanceViewItem(
+ QAbstractItemView* view_, const QModelIndex& index_)
+ : view(view_), m_index(index_)
+{
+ if (Q_UNLIKELY(!index_.isValid()))
+ qWarning() << "AccessibleInstanceViewItem::AccessibleInstanceViewItem "
+ "with invalid index: "
+ << index_;
+}
+
+void* AccessibleInstanceViewItem::interface_cast(QAccessible::InterfaceType t)
+{
+ if (t == QAccessible::TableCellInterface)
+ return static_cast<QAccessibleTableCellInterface*>(this);
+ if (t == QAccessible::ActionInterface)
+ return static_cast<QAccessibleActionInterface*>(this);
+ return 0;
+}
+
+int AccessibleInstanceViewItem::columnExtent() const
+{
+ return 1;
+}
+int AccessibleInstanceViewItem::rowExtent() const
+{
+ return 1;
+}
+
+QList<QAccessibleInterface*> AccessibleInstanceViewItem::rowHeaderCells() const
+{
+ return {};
+}
+
+QList<QAccessibleInterface*>
+AccessibleInstanceViewItem::columnHeaderCells() const
+{
+ return {};
+}
+
+int AccessibleInstanceViewItem::columnIndex() const
+{
+ if (!isValid()) {
+ return -1;
+ }
+
+ return m_index.column();
+}
+
+int AccessibleInstanceViewItem::rowIndex() const
+{
+ if (!isValid()) {
+ return -1;
+ }
+
+ return m_index.row();
+}
+
+bool AccessibleInstanceViewItem::isSelected() const
+{
+ if (!isValid()) {
+ return false;
+ }
+
+ return view->selectionModel()->isSelected(m_index);
+}
+
+QStringList AccessibleInstanceViewItem::actionNames() const
+{
+ QStringList names;
+ names << toggleAction();
+ return names;
+}
+
+void AccessibleInstanceViewItem::doAction(const QString& actionName)
+{
+ if (actionName == toggleAction()) {
+ if (isSelected()) {
+ unselectCell();
+ } else {
+ selectCell();
+ }
+ }
+}
+
+QStringList
+AccessibleInstanceViewItem::keyBindingsForAction(const QString&) const
+{
+ return QStringList();
+}
+
+void AccessibleInstanceViewItem::selectCell()
+{
+ if (!isValid()) {
+ return;
+ }
+ QAbstractItemView::SelectionMode selectionMode = view->selectionMode();
+ if (selectionMode == QAbstractItemView::NoSelection) {
+ return;
+ }
+
+ Q_ASSERT(table());
+ QAccessibleTableInterface* cellTable = table()->tableInterface();
+
+ switch (view->selectionBehavior()) {
+ case QAbstractItemView::SelectItems:
+ break;
+ case QAbstractItemView::SelectColumns:
+ if (cellTable)
+ cellTable->selectColumn(m_index.column());
+ return;
+ case QAbstractItemView::SelectRows:
+ if (cellTable)
+ cellTable->selectRow(m_index.row());
+ return;
+ }
+
+ if (selectionMode == QAbstractItemView::SingleSelection) {
+ view->clearSelection();
+ }
+
+ view->selectionModel()->select(m_index, QItemSelectionModel::Select);
+}
+
+void AccessibleInstanceViewItem::unselectCell()
+{
+ if (!isValid())
+ return;
+ QAbstractItemView::SelectionMode selectionMode = view->selectionMode();
+ if (selectionMode == QAbstractItemView::NoSelection)
+ return;
+
+ QAccessibleTableInterface* cellTable = table()->tableInterface();
+
+ switch (view->selectionBehavior()) {
+ case QAbstractItemView::SelectItems:
+ break;
+ case QAbstractItemView::SelectColumns:
+ if (cellTable)
+ cellTable->unselectColumn(m_index.column());
+ return;
+ case QAbstractItemView::SelectRows:
+ if (cellTable)
+ cellTable->unselectRow(m_index.row());
+ return;
+ }
+
+ // If the mode is not MultiSelection or ExtendedSelection and only
+ // one cell is selected it cannot be unselected by the user
+ if ((selectionMode != QAbstractItemView::MultiSelection) &&
+ (selectionMode != QAbstractItemView::ExtendedSelection) &&
+ (view->selectionModel()->selectedIndexes().count() <= 1))
+ return;
+
+ view->selectionModel()->select(m_index, QItemSelectionModel::Deselect);
+}
+
+QAccessibleInterface* AccessibleInstanceViewItem::table() const
+{
+ return QAccessible::queryAccessibleInterface(view);
+}
+
+QAccessible::Role AccessibleInstanceViewItem::role() const
+{
+ return QAccessible::ListItem;
+}
+
+QAccessible::State AccessibleInstanceViewItem::state() const
+{
+ QAccessible::State st;
+ if (!isValid())
+ return st;
+
+ QRect globalRect = view->rect();
+ globalRect.translate(view->mapToGlobal(QPoint(0, 0)));
+ if (!globalRect.intersects(rect()))
+ st.invisible = true;
+
+ if (view->selectionModel()->isSelected(m_index))
+ st.selected = true;
+ if (view->selectionModel()->currentIndex() == m_index)
+ st.focused = true;
+ if (m_index.model()->data(m_index, Qt::CheckStateRole).toInt() ==
+ Qt::Checked)
+ st.checked = true;
+
+ Qt::ItemFlags flags = m_index.flags();
+ if (flags & Qt::ItemIsSelectable) {
+ st.selectable = true;
+ st.focusable = true;
+ if (view->selectionMode() == QAbstractItemView::MultiSelection)
+ st.multiSelectable = true;
+ if (view->selectionMode() == QAbstractItemView::ExtendedSelection)
+ st.extSelectable = true;
+ }
+ return st;
+}
+
+QRect AccessibleInstanceViewItem::rect() const
+{
+ QRect r;
+ if (!isValid())
+ return r;
+ r = view->visualRect(m_index);
+
+ if (!r.isNull()) {
+ r.translate(view->viewport()->mapTo(view, QPoint(0, 0)));
+ r.translate(view->mapToGlobal(QPoint(0, 0)));
+ }
+ return r;
+}
+
+QString AccessibleInstanceViewItem::text(QAccessible::Text t) const
+{
+ QString value;
+ if (!isValid())
+ return value;
+ QAbstractItemModel* model = view->model();
+ switch (t) {
+ case QAccessible::Name:
+ value = model->data(m_index, Qt::AccessibleTextRole).toString();
+ if (value.isEmpty())
+ value = model->data(m_index, Qt::DisplayRole).toString();
+ break;
+ case QAccessible::Description:
+ value =
+ model->data(m_index, Qt::AccessibleDescriptionRole).toString();
+ break;
+ default:
+ break;
+ }
+ return value;
+}
+
+void AccessibleInstanceViewItem::setText(QAccessible::Text /*t*/,
+ const QString& text)
+{
+ if (!isValid() || !(m_index.flags() & Qt::ItemIsEditable))
+ return;
+ view->model()->setData(m_index, text);
+}
+
+bool AccessibleInstanceViewItem::isValid() const
+{
+ return view && view->model() && m_index.isValid();
+}
+
+QAccessibleInterface* AccessibleInstanceViewItem::parent() const
+{
+ return QAccessible::queryAccessibleInterface(view);
+}
+
+QAccessibleInterface* AccessibleInstanceViewItem::child(int) const
+{
+ return 0;
+}
+
+#endif /* !QT_NO_ACCESSIBILITY */
diff --git a/meshmc/launcher/ui/instanceview/AccessibleInstanceView.h b/meshmc/launcher/ui/instanceview/AccessibleInstanceView.h
new file mode 100644
index 0000000000..f6f2076f61
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/AccessibleInstanceView.h
@@ -0,0 +1,28 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QString>
+class QAccessibleInterface;
+
+QAccessibleInterface* groupViewAccessibleFactory(const QString& classname,
+ QObject* object);
diff --git a/meshmc/launcher/ui/instanceview/AccessibleInstanceView_p.h b/meshmc/launcher/ui/instanceview/AccessibleInstanceView_p.h
new file mode 100644
index 0000000000..3d47c88832
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/AccessibleInstanceView_p.h
@@ -0,0 +1,155 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "QtCore/qpointer.h"
+#include <QtGui/qaccessible.h>
+#include <QAccessibleWidget>
+#include <QAbstractItemView>
+#ifndef QT_NO_ACCESSIBILITY
+#include "InstanceView.h"
+// #include <QHeaderView>
+
+class QAccessibleTableCell;
+class QAccessibleTableHeaderCell;
+
+class AccessibleInstanceView : public QAccessibleTableInterface,
+ public QAccessibleObject
+{
+ public:
+ explicit AccessibleInstanceView(QWidget* w);
+ bool isValid() const override;
+
+ QAccessible::Role role() const override;
+ QAccessible::State state() const override;
+ QString text(QAccessible::Text t) const override;
+ QRect rect() const override;
+
+ QAccessibleInterface* childAt(int x, int y) const override;
+ int childCount() const override;
+ int indexOfChild(const QAccessibleInterface*) const override;
+
+ QAccessibleInterface* parent() const override;
+ QAccessibleInterface* child(int index) const override;
+
+ void* interface_cast(QAccessible::InterfaceType t) override;
+
+ // table interface
+ QAccessibleInterface* cellAt(int row, int column) const override;
+ QAccessibleInterface* caption() const override;
+ QAccessibleInterface* summary() const override;
+ QString columnDescription(int column) const override;
+ QString rowDescription(int row) const override;
+ int columnCount() const override;
+ int rowCount() const override;
+
+ // selection
+ int selectedCellCount() const override;
+ int selectedColumnCount() const override;
+ int selectedRowCount() const override;
+ QList<QAccessibleInterface*> selectedCells() const override;
+ QList<int> selectedColumns() const override;
+ QList<int> selectedRows() const override;
+ bool isColumnSelected(int column) const override;
+ bool isRowSelected(int row) const override;
+ bool selectRow(int row) override;
+ bool selectColumn(int column) override;
+ bool unselectRow(int row) override;
+ bool unselectColumn(int column) override;
+
+ QAbstractItemView* view() const;
+
+ void modelChange(QAccessibleTableModelChangeEvent* event) override;
+
+ protected:
+ // maybe vector
+ typedef QHash<int, QAccessible::Id> ChildCache;
+ mutable ChildCache childToId;
+
+ virtual ~AccessibleInstanceView();
+
+ private:
+ inline int logicalIndex(const QModelIndex& index) const;
+};
+
+class AccessibleInstanceViewItem : public QAccessibleInterface,
+ public QAccessibleTableCellInterface,
+ public QAccessibleActionInterface
+{
+ public:
+ AccessibleInstanceViewItem(QAbstractItemView* view,
+ const QModelIndex& m_index);
+
+ void* interface_cast(QAccessible::InterfaceType t) override;
+ QObject* object() const override
+ {
+ return nullptr;
+ }
+ QAccessible::Role role() const override;
+ QAccessible::State state() const override;
+ QRect rect() const override;
+ bool isValid() const override;
+
+ QAccessibleInterface* childAt(int, int) const override
+ {
+ return nullptr;
+ }
+ int childCount() const override
+ {
+ return 0;
+ }
+ int indexOfChild(const QAccessibleInterface*) const override
+ {
+ return -1;
+ }
+
+ QString text(QAccessible::Text t) const override;
+ void setText(QAccessible::Text t, const QString& text) override;
+
+ QAccessibleInterface* parent() const override;
+ QAccessibleInterface* child(int) const override;
+
+ // cell interface
+ int columnExtent() const override;
+ QList<QAccessibleInterface*> columnHeaderCells() const override;
+ int columnIndex() const override;
+ int rowExtent() const override;
+ QList<QAccessibleInterface*> rowHeaderCells() const override;
+ int rowIndex() const override;
+ bool isSelected() const override;
+ QAccessibleInterface* table() const override;
+
+ // action interface
+ QStringList actionNames() const override;
+ void doAction(const QString& actionName) override;
+ QStringList keyBindingsForAction(const QString& actionName) const override;
+
+ private:
+ QPointer<QAbstractItemView> view;
+ QPersistentModelIndex m_index;
+
+ void selectCell();
+ void unselectCell();
+
+ friend class AccessibleInstanceView;
+};
+#endif /* !QT_NO_ACCESSIBILITY */
diff --git a/meshmc/launcher/ui/instanceview/InstanceDelegate.cpp b/meshmc/launcher/ui/instanceview/InstanceDelegate.cpp
new file mode 100644
index 0000000000..c4c4f254d0
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/InstanceDelegate.cpp
@@ -0,0 +1,462 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "InstanceDelegate.h"
+#include <QPainter>
+#include <QTextOption>
+#include <QTextLayout>
+#include <QApplication>
+#include <QtMath>
+#include <QDebug>
+
+#include "InstanceView.h"
+#include "BaseInstance.h"
+#include "InstanceList.h"
+#include <xdgicon.h>
+#include <QTextEdit>
+
+// Origin: Qt
+static void viewItemTextLayout(QTextLayout& textLayout, int lineWidth,
+ qreal& height, qreal& widthUsed)
+{
+ height = 0;
+ widthUsed = 0;
+ textLayout.beginLayout();
+ QString str = textLayout.text();
+ while (true) {
+ QTextLine line = textLayout.createLine();
+ if (!line.isValid())
+ break;
+ if (line.textLength() == 0)
+ break;
+ line.setLineWidth(lineWidth);
+ line.setPosition(QPointF(0, height));
+ height += line.height();
+ widthUsed = qMax(widthUsed, line.naturalTextWidth());
+ }
+ textLayout.endLayout();
+}
+
+ListViewDelegate::ListViewDelegate(QObject* parent)
+ : QStyledItemDelegate(parent)
+{
+}
+
+void drawSelectionRect(QPainter* painter, const QStyleOptionViewItem& option,
+ const QRect& rect)
+{
+ if ((option.state & QStyle::State_Selected))
+ painter->fillRect(rect, option.palette.brush(QPalette::Highlight));
+ else {
+ QColor backgroundColor = option.palette.color(QPalette::Window);
+ backgroundColor.setAlpha(160);
+ painter->fillRect(rect, QBrush(backgroundColor));
+ }
+}
+
+void drawFocusRect(QPainter* painter, const QStyleOptionViewItem& option,
+ const QRect& rect)
+{
+ if (!(option.state & QStyle::State_HasFocus))
+ return;
+ QStyleOptionFocusRect opt;
+ opt.direction = option.direction;
+ opt.fontMetrics = option.fontMetrics;
+ opt.palette = option.palette;
+ opt.rect = rect;
+ // opt.state = option.state | QStyle::State_KeyboardFocusChange |
+ // QStyle::State_Item;
+ auto col = option.state & QStyle::State_Selected ? QPalette::Highlight
+ : QPalette::Base;
+ opt.backgroundColor = option.palette.color(col);
+ // Apparently some widget styles expect this hint to not be set
+ painter->setRenderHint(QPainter::Antialiasing, false);
+
+ QStyle* style =
+ option.widget ? option.widget->style() : QApplication::style();
+
+ style->drawPrimitive(QStyle::PE_FrameFocusRect, &opt, painter,
+ option.widget);
+
+ painter->setRenderHint(QPainter::Antialiasing);
+}
+
+// TODO this can be made a lot prettier
+void drawProgressOverlay(QPainter* painter, const QStyleOptionViewItem& option,
+ const int value, const int maximum)
+{
+ if (maximum == 0 || value == maximum) {
+ return;
+ }
+
+ painter->save();
+
+ qreal percent = (qreal)value / (qreal)maximum;
+ QColor color = option.palette.color(QPalette::Dark);
+ color.setAlphaF(0.70f);
+ painter->setBrush(color);
+ painter->setPen(QPen(QBrush(), 0));
+ painter->drawPie(option.rect, 90 * 16, -percent * 360 * 16);
+
+ painter->restore();
+}
+
+void drawBadges(QPainter* painter, const QStyleOptionViewItem& option,
+ BaseInstance* instance, QIcon::Mode mode, QIcon::State state)
+{
+ QList<QString> pixmaps;
+ if (instance->isRunning()) {
+ pixmaps.append("status-running");
+ } else if (instance->hasCrashed() || instance->hasVersionBroken()) {
+ pixmaps.append("status-bad");
+ }
+ if (instance->hasUpdateAvailable()) {
+ pixmaps.append("checkupdate");
+ }
+
+ static const int itemSide = 24;
+ static const int spacing = 1;
+ const int itemsPerRow =
+ qMax(1, qFloor(double(option.rect.width() + spacing) /
+ double(itemSide + spacing)));
+ const int rows = qCeil((double)pixmaps.size() / (double)itemsPerRow);
+ QListIterator<QString> it(pixmaps);
+ painter->translate(option.rect.topLeft());
+ for (int y = 0; y < rows; ++y) {
+ for (int x = 0; x < itemsPerRow; ++x) {
+ if (!it.hasNext()) {
+ return;
+ }
+ // FIXME: inject this.
+ auto icon = XdgIcon::fromTheme(it.next());
+ // opt.icon.paint(painter, iconbox, Qt::AlignCenter, mode, state);
+ const QPixmap pixmap;
+ // itemSide
+ QRect badgeRect(option.rect.width() - x * itemSide +
+ qMax(x - 1, 0) * spacing - itemSide,
+ y * itemSide + qMax(y - 1, 0) * spacing, itemSide,
+ itemSide);
+ icon.paint(painter, badgeRect, Qt::AlignCenter, mode, state);
+ }
+ }
+ painter->translate(-option.rect.topLeft());
+}
+
+static QSize viewItemTextSize(const QStyleOptionViewItem* option)
+{
+ QStyle* style =
+ option->widget ? option->widget->style() : QApplication::style();
+ QTextOption textOption;
+ textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
+ QTextLayout textLayout;
+ textLayout.setTextOption(textOption);
+ textLayout.setFont(option->font);
+ textLayout.setText(option->text);
+ const int textMargin = style->pixelMetric(QStyle::PM_FocusFrameHMargin,
+ option, option->widget) +
+ 1;
+ QRect bounds(0, 0, 100 - 2 * textMargin, 600);
+ qreal height = 0, widthUsed = 0;
+ viewItemTextLayout(textLayout, bounds.width(), height, widthUsed);
+ const QSize size(qCeil(widthUsed), qCeil(height));
+ return QSize(size.width() + 2 * textMargin, size.height());
+}
+
+void ListViewDelegate::paint(QPainter* painter,
+ const QStyleOptionViewItem& option,
+ const QModelIndex& index) const
+{
+ QStyleOptionViewItem opt = option;
+ initStyleOption(&opt, index);
+ painter->save();
+ painter->setClipRect(opt.rect);
+
+ opt.features |= QStyleOptionViewItem::WrapText;
+ opt.text = index.data().toString();
+ opt.textElideMode = Qt::ElideRight;
+ opt.displayAlignment = Qt::AlignTop | Qt::AlignHCenter;
+
+ QStyle* style = opt.widget ? opt.widget->style() : QApplication::style();
+
+ // const int iconSize = style->pixelMetric(QStyle::PM_IconViewIconSize);
+ const int iconSize = 48;
+ QRect iconbox = opt.rect;
+ const int textMargin =
+ style->pixelMetric(QStyle::PM_FocusFrameHMargin, 0, opt.widget) + 1;
+ QRect textRect = opt.rect;
+ QRect textHighlightRect = textRect;
+ // clip the decoration on top, remove width padding
+ textRect.adjust(textMargin, iconSize + textMargin + 5, -textMargin, 0);
+
+ textHighlightRect.adjust(0, iconSize + 5, 0, 0);
+
+ // draw background
+ {
+ // FIXME: unused
+ // QSize textSize = viewItemTextSize ( &opt );
+ drawSelectionRect(painter, opt, textHighlightRect);
+ /*
+ QPalette::ColorGroup cg;
+ QStyleOptionViewItem opt2(opt);
+
+ if ((opt.widget && opt.widget->isEnabled()) || (opt.state &
+ QStyle::State_Enabled))
+ {
+ if (!(opt.state & QStyle::State_Active))
+ cg = QPalette::Inactive;
+ else
+ cg = QPalette::Normal;
+ }
+ else
+ {
+ cg = QPalette::Disabled;
+ }
+ */
+ /*
+ opt2.palette.setCurrentColorGroup(cg);
+
+ // fill in background, if any
+
+
+ if (opt.backgroundBrush.style() != Qt::NoBrush)
+ {
+ QPointF oldBO = painter->brushOrigin();
+ painter->setBrushOrigin(opt.rect.topLeft());
+ painter->fillRect(opt.rect, opt.backgroundBrush);
+ painter->setBrushOrigin(oldBO);
+ }
+
+ drawSelectionRect(painter, opt2, textHighlightRect);
+ */
+
+ /*
+ if (opt.showDecorationSelected)
+ {
+ drawSelectionRect(painter, opt2, opt.rect);
+ drawFocusRect(painter, opt2, opt.rect);
+ // painter->fillRect ( opt.rect, opt.palette.brush ( cg,
+ QPalette::Highlight ) );
+ }
+ else
+ {
+
+ // if ( opt.state & QStyle::State_Selected )
+ {
+ // QRect textRect = subElementRect (
+ QStyle::SE_ItemViewItemText, opt,
+ // opt.widget );
+ // painter->fillRect ( textHighlightRect, opt.palette.brush (
+ cg,
+ // QPalette::Highlight ) );
+ drawSelectionRect(painter, opt2, textHighlightRect);
+ drawFocusRect(painter, opt2, textHighlightRect);
+ }
+ }
+ */
+ }
+
+ // icon mode and state, also used for badges
+ QIcon::Mode mode = QIcon::Normal;
+ if (!(opt.state & QStyle::State_Enabled))
+ mode = QIcon::Disabled;
+ else if (opt.state & QStyle::State_Selected)
+ mode = QIcon::Selected;
+ QIcon::State state =
+ opt.state & QStyle::State_Open ? QIcon::On : QIcon::Off;
+
+ // draw the icon
+ {
+ iconbox.setHeight(iconSize);
+ opt.icon.paint(painter, iconbox, Qt::AlignCenter, mode, state);
+ }
+ // set the text colors
+ QPalette::ColorGroup cg = opt.state & QStyle::State_Enabled
+ ? QPalette::Normal
+ : QPalette::Disabled;
+ if (cg == QPalette::Normal && !(opt.state & QStyle::State_Active))
+ cg = QPalette::Inactive;
+ if (opt.state & QStyle::State_Selected) {
+ painter->setPen(opt.palette.color(cg, QPalette::HighlightedText));
+ } else {
+ painter->setPen(opt.palette.color(cg, QPalette::Text));
+ }
+
+ // draw the text
+ QTextOption textOption;
+ textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
+ textOption.setTextDirection(opt.direction);
+ textOption.setAlignment(
+ QStyle::visualAlignment(opt.direction, opt.displayAlignment));
+ QTextLayout textLayout;
+ textLayout.setTextOption(textOption);
+ textLayout.setFont(opt.font);
+ textLayout.setText(opt.text);
+
+ qreal width, height;
+ viewItemTextLayout(textLayout, textRect.width(), height, width);
+
+ const int lineCount = textLayout.lineCount();
+
+ const QRect layoutRect =
+ QStyle::alignedRect(opt.direction, opt.displayAlignment,
+ QSize(textRect.width(), int(height)), textRect);
+ const QPointF position = layoutRect.topLeft();
+ for (int i = 0; i < lineCount; ++i) {
+ const QTextLine line = textLayout.lineAt(i);
+ line.draw(painter, position);
+ }
+
+ // FIXME: this really has no business of being here. Make generic.
+ auto instance = (BaseInstance*)index.data(InstanceList::InstancePointerRole)
+ .value<void*>();
+ if (instance) {
+ drawBadges(painter, opt, instance, mode, state);
+ }
+
+ drawProgressOverlay(
+ painter, opt, index.data(InstanceViewRoles::ProgressValueRole).toInt(),
+ index.data(InstanceViewRoles::ProgressMaximumRole).toInt());
+
+ painter->restore();
+}
+
+QSize ListViewDelegate::sizeHint(const QStyleOptionViewItem& option,
+ const QModelIndex& index) const
+{
+ QStyleOptionViewItem opt = option;
+ initStyleOption(&opt, index);
+ opt.features |= QStyleOptionViewItem::WrapText;
+ opt.text = index.data().toString();
+ opt.textElideMode = Qt::ElideRight;
+ opt.displayAlignment = Qt::AlignTop | Qt::AlignHCenter;
+
+ QStyle* style = opt.widget ? opt.widget->style() : QApplication::style();
+ const int textMargin =
+ style->pixelMetric(QStyle::PM_FocusFrameHMargin, &option, opt.widget) +
+ 1;
+ int height = 48 + textMargin * 2 + 5; // TODO: turn constants into variables
+ QSize szz = viewItemTextSize(&opt);
+ height += szz.height();
+ // FIXME: maybe the icon items could scale and keep proportions?
+ QSize sz(100, height);
+ return sz;
+}
+
+class NoReturnTextEdit : public QTextEdit
+{
+ Q_OBJECT
+ public:
+ explicit NoReturnTextEdit(QWidget* parent) : QTextEdit(parent)
+ {
+ setTextInteractionFlags(Qt::TextEditorInteraction);
+ setHorizontalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAlwaysOff);
+ setVerticalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAlwaysOff);
+ }
+ bool event(QEvent* event) override
+ {
+ auto eventType = event->type();
+ if (eventType == QEvent::KeyPress || eventType == QEvent::KeyRelease) {
+ QKeyEvent* keyEvent = static_cast<QKeyEvent*>(event);
+ auto key = keyEvent->key();
+ if (key == Qt::Key_Return || key == Qt::Key_Enter) {
+ emit editingDone();
+ return true;
+ }
+ if (key == Qt::Key_Tab) {
+ return true;
+ }
+ }
+ return QTextEdit::event(event);
+ }
+ signals:
+ void editingDone();
+};
+
+void ListViewDelegate::updateEditorGeometry(QWidget* editor,
+ const QStyleOptionViewItem& option,
+ const QModelIndex& index) const
+{
+ const int iconSize = 48;
+ QRect textRect = option.rect;
+ // QStyle *style = option.widget ? option.widget->style() :
+ // QApplication::style();
+ textRect.adjust(0, iconSize + 5, 0, 0);
+ editor->setGeometry(textRect);
+}
+
+void ListViewDelegate::setEditorData(QWidget* editor,
+ const QModelIndex& index) const
+{
+ auto text = index.data(Qt::EditRole).toString();
+ QTextEdit* realeditor = qobject_cast<NoReturnTextEdit*>(editor);
+ realeditor->setAlignment(Qt::AlignHCenter | Qt::AlignTop);
+ realeditor->append(text);
+ realeditor->selectAll();
+ realeditor->document()->clearUndoRedoStacks();
+}
+
+void ListViewDelegate::setModelData(QWidget* editor, QAbstractItemModel* model,
+ const QModelIndex& index) const
+{
+ QTextEdit* realeditor = qobject_cast<NoReturnTextEdit*>(editor);
+ QString text = realeditor->toPlainText();
+ text.replace(QChar('\n'), QChar(' '));
+ text = text.trimmed();
+ if (text.size() != 0) {
+ model->setData(index, text);
+ }
+}
+
+QWidget* ListViewDelegate::createEditor(QWidget* parent,
+ const QStyleOptionViewItem& option,
+ const QModelIndex& index) const
+{
+ auto editor = new NoReturnTextEdit(parent);
+ connect(editor, &NoReturnTextEdit::editingDone, this,
+ &ListViewDelegate::editingDone);
+ return editor;
+}
+
+void ListViewDelegate::editingDone()
+{
+ NoReturnTextEdit* editor = qobject_cast<NoReturnTextEdit*>(sender());
+ emit commitData(editor);
+ emit closeEditor(editor);
+}
+
+#include "InstanceDelegate.moc"
diff --git a/meshmc/launcher/ui/instanceview/InstanceDelegate.h b/meshmc/launcher/ui/instanceview/InstanceDelegate.h
new file mode 100644
index 0000000000..36df302aca
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/InstanceDelegate.h
@@ -0,0 +1,69 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QStyledItemDelegate>
+#include <QCache>
+
+class ListViewDelegate : public QStyledItemDelegate
+{
+ Q_OBJECT
+
+ public:
+ explicit ListViewDelegate(QObject* parent = 0);
+ virtual ~ListViewDelegate() {}
+
+ void paint(QPainter* painter, const QStyleOptionViewItem& option,
+ const QModelIndex& index) const override;
+ QSize sizeHint(const QStyleOptionViewItem& option,
+ const QModelIndex& index) const override;
+ void updateEditorGeometry(QWidget* editor,
+ const QStyleOptionViewItem& option,
+ const QModelIndex& index) const override;
+ QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem& option,
+ const QModelIndex& index) const override;
+
+ void setEditorData(QWidget* editor,
+ const QModelIndex& index) const override;
+ void setModelData(QWidget* editor, QAbstractItemModel* model,
+ const QModelIndex& index) const override;
+
+ private slots:
+ void editingDone();
+};
diff --git a/meshmc/launcher/ui/instanceview/InstanceProxyModel.cpp b/meshmc/launcher/ui/instanceview/InstanceProxyModel.cpp
new file mode 100644
index 0000000000..93de0231a1
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/InstanceProxyModel.cpp
@@ -0,0 +1,100 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "InstanceProxyModel.h"
+
+#include "InstanceView.h"
+#include "Application.h"
+#include <BaseInstance.h>
+#include <icons/IconList.h>
+
+#include <QDebug>
+
+InstanceProxyModel::InstanceProxyModel(QObject* parent)
+ : QSortFilterProxyModel(parent)
+{
+ m_naturalSort.setNumericMode(true);
+ m_naturalSort.setCaseSensitivity(Qt::CaseSensitivity::CaseInsensitive);
+ // FIXME: use loaded translation as source of locale instead, hook this up
+ // to translation changes
+ m_naturalSort.setLocale(QLocale::system());
+}
+
+QVariant InstanceProxyModel::data(const QModelIndex& index, int role) const
+{
+ QVariant data = QSortFilterProxyModel::data(index, role);
+ if (role == Qt::DecorationRole) {
+ return QVariant(APPLICATION->icons()->getIcon(data.toString()));
+ }
+ return data;
+}
+
+bool InstanceProxyModel::lessThan(const QModelIndex& left,
+ const QModelIndex& right) const
+{
+ const QString leftCategory =
+ left.data(InstanceViewRoles::GroupRole).toString();
+ const QString rightCategory =
+ right.data(InstanceViewRoles::GroupRole).toString();
+ if (leftCategory == rightCategory) {
+ return subSortLessThan(left, right);
+ } else {
+ // FIXME: real group sorting happens in
+ // InstanceView::updateGeometries(), see LocaleString
+ auto result = leftCategory.localeAwareCompare(rightCategory);
+ if (result == 0) {
+ return subSortLessThan(left, right);
+ }
+ return result < 0;
+ }
+}
+
+bool InstanceProxyModel::subSortLessThan(const QModelIndex& left,
+ const QModelIndex& right) const
+{
+ BaseInstance* pdataLeft =
+ static_cast<BaseInstance*>(left.internalPointer());
+ BaseInstance* pdataRight =
+ static_cast<BaseInstance*>(right.internalPointer());
+ QString sortMode = APPLICATION->settings()->get("InstSortMode").toString();
+ if (sortMode == "LastLaunch") {
+ return pdataLeft->lastLaunch() > pdataRight->lastLaunch();
+ } else {
+ return m_naturalSort.compare(pdataLeft->name(), pdataRight->name()) < 0;
+ }
+}
diff --git a/meshmc/launcher/ui/instanceview/InstanceProxyModel.h b/meshmc/launcher/ui/instanceview/InstanceProxyModel.h
new file mode 100644
index 0000000000..898f01b57d
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/InstanceProxyModel.h
@@ -0,0 +1,60 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QSortFilterProxyModel>
+#include <QCollator>
+
+class InstanceProxyModel : public QSortFilterProxyModel
+{
+ Q_OBJECT
+
+ public:
+ InstanceProxyModel(QObject* parent = 0);
+
+ protected:
+ QVariant data(const QModelIndex& index, int role) const override;
+ bool lessThan(const QModelIndex& left,
+ const QModelIndex& right) const override;
+ bool subSortLessThan(const QModelIndex& left,
+ const QModelIndex& right) const;
+
+ private:
+ QCollator m_naturalSort;
+};
diff --git a/meshmc/launcher/ui/instanceview/InstanceView.cpp b/meshmc/launcher/ui/instanceview/InstanceView.cpp
new file mode 100644
index 0000000000..df5e772e1f
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/InstanceView.cpp
@@ -0,0 +1,952 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "InstanceView.h"
+
+#include <QPainter>
+#include <QApplication>
+#include <QtMath>
+#include <QMouseEvent>
+#include <QListView>
+#include <QPersistentModelIndex>
+#include <QDrag>
+#include <QMimeData>
+#include <QCache>
+#include <QScrollBar>
+#include <QAccessible>
+
+#include "VisualGroup.h"
+#include <QDebug>
+
+#include <Application.h>
+#include <InstanceList.h>
+
+template <typename T> bool listsIntersect(const QList<T>& l1, const QList<T> t2)
+{
+ for (auto& item : l1) {
+ if (t2.contains(item)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+InstanceView::InstanceView(QWidget* parent) : QAbstractItemView(parent)
+{
+ setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
+ setAcceptDrops(true);
+ setAutoScroll(true);
+}
+
+InstanceView::~InstanceView()
+{
+ qDeleteAll(m_groups);
+ m_groups.clear();
+}
+
+void InstanceView::setModel(QAbstractItemModel* model)
+{
+ QAbstractItemView::setModel(model);
+ connect(model, &QAbstractItemModel::modelReset, this,
+ &InstanceView::modelReset);
+ connect(model, &QAbstractItemModel::rowsRemoved, this,
+ &InstanceView::rowsRemoved);
+}
+
+void InstanceView::dataChanged(const QModelIndex& topLeft,
+ const QModelIndex& bottomRight,
+ const QVector<int>& roles)
+{
+ scheduleDelayedItemsLayout();
+}
+void InstanceView::rowsInserted(const QModelIndex& parent, int start, int end)
+{
+ scheduleDelayedItemsLayout();
+}
+
+void InstanceView::rowsAboutToBeRemoved(const QModelIndex& parent, int start,
+ int end)
+{
+ scheduleDelayedItemsLayout();
+}
+
+void InstanceView::modelReset()
+{
+ scheduleDelayedItemsLayout();
+}
+
+void InstanceView::rowsRemoved()
+{
+ scheduleDelayedItemsLayout();
+}
+
+void InstanceView::currentChanged(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ QAbstractItemView::currentChanged(current, previous);
+ // TODO: for accessibility support, implement+register a factory, steal
+ // QAccessibleTable from Qt and return an instance of it for InstanceView.
+#ifndef QT_NO_ACCESSIBILITY
+ if (QAccessible::isActive() && current.isValid()) {
+ QAccessibleEvent event(this, QAccessible::Focus);
+ event.setChild(current.row());
+ QAccessible::updateAccessibility(&event);
+ }
+#endif /* !QT_NO_ACCESSIBILITY */
+}
+
+class LocaleString : public QString
+{
+ public:
+ LocaleString(const char* s) : QString(s) {}
+ LocaleString(const QString& s) : QString(s) {}
+};
+
+inline bool operator<(const LocaleString& lhs, const LocaleString& rhs)
+{
+ return (QString::localeAwareCompare(lhs, rhs) < 0);
+}
+
+void InstanceView::updateScrollbar()
+{
+ int previousScroll = verticalScrollBar()->value();
+ if (m_groups.isEmpty()) {
+ verticalScrollBar()->setRange(0, 0);
+ } else {
+ int totalHeight = 0;
+ // top margin
+ totalHeight += m_categoryMargin;
+ int itemScroll = 0;
+ for (auto category : m_groups) {
+ category->m_verticalPosition = totalHeight;
+ totalHeight += category->totalHeight() + m_categoryMargin;
+ if (!itemScroll && category->totalHeight() != 0) {
+ itemScroll = category->contentHeight() / category->numRows();
+ }
+ }
+ // do not divide by zero
+ if (itemScroll == 0)
+ itemScroll = 64;
+
+ totalHeight += m_bottomMargin;
+ verticalScrollBar()->setSingleStep(itemScroll);
+ const int rowsPerPage = qMax(viewport()->height() / itemScroll, 1);
+ verticalScrollBar()->setPageStep(rowsPerPage * itemScroll);
+
+ verticalScrollBar()->setRange(0, totalHeight - height());
+ }
+
+ verticalScrollBar()->setValue(
+ qMin(previousScroll, verticalScrollBar()->maximum()));
+}
+
+void InstanceView::updateGeometries()
+{
+ geometryCache.clear();
+
+ QMap<LocaleString, VisualGroup*> cats;
+
+ for (int i = 0; i < model()->rowCount(); ++i) {
+ const QString groupName =
+ model()->index(i, 0).data(InstanceViewRoles::GroupRole).toString();
+ if (!cats.contains(groupName)) {
+ VisualGroup* old = this->category(groupName);
+ if (old) {
+ auto cat = new VisualGroup(old);
+ cats.insert(groupName, cat);
+ cat->update();
+ } else {
+ auto cat = new VisualGroup(groupName, this);
+ if (fVisibility) {
+ cat->collapsed = fVisibility(groupName);
+ }
+ cats.insert(groupName, cat);
+ cat->update();
+ }
+ }
+ }
+
+ qDeleteAll(m_groups);
+ m_groups = cats.values();
+ updateScrollbar();
+ viewport()->update();
+}
+
+bool InstanceView::isIndexHidden(const QModelIndex& index) const
+{
+ VisualGroup* cat = category(index);
+ if (cat) {
+ return cat->collapsed;
+ } else {
+ return false;
+ }
+}
+
+VisualGroup* InstanceView::category(const QModelIndex& index) const
+{
+ return category(index.data(InstanceViewRoles::GroupRole).toString());
+}
+
+VisualGroup* InstanceView::category(const QString& cat) const
+{
+ for (auto group : m_groups) {
+ if (group->text == cat) {
+ return group;
+ }
+ }
+ return nullptr;
+}
+
+VisualGroup* InstanceView::categoryAt(const QPoint& pos,
+ VisualGroup::HitResults& result) const
+{
+ for (auto group : m_groups) {
+ result = group->hitScan(pos);
+ if (result != VisualGroup::NoHit) {
+ return group;
+ }
+ }
+ result = VisualGroup::NoHit;
+ return nullptr;
+}
+
+QString InstanceView::groupNameAt(const QPoint& point)
+{
+ executeDelayedItemsLayout();
+
+ VisualGroup::HitResults hitresult;
+ auto group = categoryAt(point + offset(), hitresult);
+ if (group &&
+ (hitresult & (VisualGroup::HeaderHit | VisualGroup::BodyHit))) {
+ return group->text;
+ }
+ return QString();
+}
+
+int InstanceView::calculateItemsPerRow() const
+{
+ return qFloor((qreal)(contentWidth()) / (qreal)(itemWidth() + m_spacing));
+}
+
+int InstanceView::contentWidth() const
+{
+ return width() - m_leftMargin - m_rightMargin;
+}
+
+int InstanceView::itemWidth() const
+{
+ return m_itemWidth;
+}
+
+void InstanceView::mousePressEvent(QMouseEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ QPoint visualPos = event->pos();
+ QPoint geometryPos = event->pos() + offset();
+
+ QPersistentModelIndex index = indexAt(visualPos);
+
+ m_pressedIndex = index;
+ m_pressedAlreadySelected = selectionModel()->isSelected(m_pressedIndex);
+ m_pressedPosition = geometryPos;
+
+ VisualGroup::HitResults hitresult;
+ m_pressedCategory = categoryAt(geometryPos, hitresult);
+ if (m_pressedCategory && hitresult & VisualGroup::CheckboxHit) {
+ setState(m_pressedCategory->collapsed ? ExpandingState
+ : CollapsingState);
+ event->accept();
+ return;
+ }
+
+ if (index.isValid() && (index.flags() & Qt::ItemIsEnabled)) {
+ if (index != currentIndex()) {
+ // FIXME: better!
+ m_currentCursorColumn = -1;
+ }
+ // we disable scrollTo for mouse press so the item doesn't change
+ // position when the user is interacting with it (ie. clicking on it)
+ bool autoScroll = hasAutoScroll();
+ setAutoScroll(false);
+ selectionModel()->setCurrentIndex(index, QItemSelectionModel::NoUpdate);
+
+ setAutoScroll(autoScroll);
+ QRect rect(visualPos, visualPos);
+ setSelection(rect, QItemSelectionModel::ClearAndSelect);
+
+ // signal handlers may change the model
+ emit pressed(index);
+ } else {
+ // Forces a finalize() even if mouse is pressed, but not on a item
+ selectionModel()->select(QModelIndex(), QItemSelectionModel::Select);
+ }
+}
+
+void InstanceView::mouseMoveEvent(QMouseEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ QPoint topLeft;
+ QPoint visualPos = event->pos();
+ QPoint geometryPos = event->pos() + offset();
+
+ if (state() == ExpandingState || state() == CollapsingState) {
+ return;
+ }
+
+ if (state() == DraggingState) {
+ topLeft = m_pressedPosition - offset();
+ if ((topLeft - event->pos()).manhattanLength() >
+ QApplication::startDragDistance()) {
+ m_pressedIndex = QModelIndex();
+ startDrag(model()->supportedDragActions());
+ setState(NoState);
+ stopAutoScroll();
+ }
+ return;
+ }
+
+ if (selectionMode() != SingleSelection) {
+ topLeft = m_pressedPosition - offset();
+ } else {
+ topLeft = geometryPos;
+ }
+
+ if (m_pressedIndex.isValid() && (state() != DragSelectingState) &&
+ (event->buttons() != Qt::NoButton) && !selectedIndexes().isEmpty()) {
+ setState(DraggingState);
+ return;
+ }
+
+ if ((event->buttons() & Qt::LeftButton) && selectionModel()) {
+ setState(DragSelectingState);
+
+ setSelection(QRect(visualPos, visualPos),
+ QItemSelectionModel::ClearAndSelect);
+ QModelIndex index = indexAt(visualPos);
+
+ // set at the end because it might scroll the view
+ if (index.isValid() && (index != selectionModel()->currentIndex()) &&
+ (index.flags() & Qt::ItemIsEnabled)) {
+ selectionModel()->setCurrentIndex(index,
+ QItemSelectionModel::NoUpdate);
+ }
+ }
+}
+
+void InstanceView::mouseReleaseEvent(QMouseEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ QPoint visualPos = event->pos();
+ QPoint geometryPos = event->pos() + offset();
+ QPersistentModelIndex index = indexAt(visualPos);
+
+ VisualGroup::HitResults hitresult;
+
+ bool click = (index == m_pressedIndex && index.isValid()) ||
+ (m_pressedCategory &&
+ m_pressedCategory == categoryAt(geometryPos, hitresult));
+
+ if (click && m_pressedCategory) {
+ if (state() == ExpandingState) {
+ m_pressedCategory->collapsed = false;
+ emit groupStateChanged(m_pressedCategory->text, false);
+
+ updateGeometries();
+ viewport()->update();
+ event->accept();
+ m_pressedCategory = nullptr;
+ setState(NoState);
+ return;
+ } else if (state() == CollapsingState) {
+ m_pressedCategory->collapsed = true;
+ emit groupStateChanged(m_pressedCategory->text, true);
+
+ updateGeometries();
+ viewport()->update();
+ event->accept();
+ m_pressedCategory = nullptr;
+ setState(NoState);
+ return;
+ }
+ }
+
+ m_ctrlDragSelectionFlag = QItemSelectionModel::NoUpdate;
+
+ setState(NoState);
+
+ if (click) {
+ if (event->button() == Qt::LeftButton) {
+ emit clicked(index);
+ }
+ QStyleOptionViewItem option = viewOptions();
+ if (m_pressedAlreadySelected) {
+ option.state |= QStyle::State_Selected;
+ }
+ if ((model()->flags(index) & Qt::ItemIsEnabled) &&
+ style()->styleHint(QStyle::SH_ItemView_ActivateItemOnSingleClick,
+ &option, this)) {
+ emit activated(index);
+ }
+ }
+}
+
+void InstanceView::mouseDoubleClickEvent(QMouseEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ QModelIndex index = indexAt(event->pos());
+ if (!index.isValid() || !(index.flags() & Qt::ItemIsEnabled) ||
+ (m_pressedIndex != index)) {
+ QMouseEvent me(QEvent::MouseButtonPress, event->localPos(),
+ event->windowPos(), event->screenPos(), event->button(),
+ event->buttons(), event->modifiers());
+ mousePressEvent(&me);
+ return;
+ }
+ // signal handlers may change the model
+ QPersistentModelIndex persistent = index;
+ emit doubleClicked(persistent);
+
+ QStyleOptionViewItem option = viewOptions();
+ if ((model()->flags(index) & Qt::ItemIsEnabled) &&
+ !style()->styleHint(QStyle::SH_ItemView_ActivateItemOnSingleClick,
+ &option, this)) {
+ emit activated(index);
+ }
+}
+
+void InstanceView::paintEvent(QPaintEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ QPainter painter(this->viewport());
+
+ QStyleOptionViewItem option(viewOptions());
+ option.widget = this;
+
+ int wpWidth = viewport()->width();
+ option.rect.setWidth(wpWidth);
+ for (int i = 0; i < m_groups.size(); ++i) {
+ VisualGroup* category = m_groups.at(i);
+ int y = category->verticalPosition();
+ y -= verticalOffset();
+ QRect backup = option.rect;
+ int height = category->totalHeight();
+ option.rect.setTop(y);
+ option.rect.setHeight(height);
+ option.rect.setLeft(m_leftMargin);
+ option.rect.setRight(wpWidth - m_rightMargin);
+ category->drawHeader(&painter, option);
+ y += category->totalHeight() + m_categoryMargin;
+ option.rect = backup;
+ }
+
+ for (int i = 0; i < model()->rowCount(); ++i) {
+ const QModelIndex index = model()->index(i, 0);
+ if (isIndexHidden(index)) {
+ continue;
+ }
+ Qt::ItemFlags flags = index.flags();
+ option.rect = visualRect(index);
+ option.features |= QStyleOptionViewItem::WrapText;
+ if (flags & Qt::ItemIsSelectable &&
+ selectionModel()->isSelected(index)) {
+ option.state |= selectionModel()->isSelected(index)
+ ? QStyle::State_Selected
+ : QStyle::State_None;
+ } else {
+ option.state &= ~QStyle::State_Selected;
+ }
+ option.state |= (index == currentIndex()) ? QStyle::State_HasFocus
+ : QStyle::State_None;
+ if (!(flags & Qt::ItemIsEnabled)) {
+ option.state &= ~QStyle::State_Enabled;
+ }
+ itemDelegate()->paint(&painter, option, index);
+ }
+
+ /*
+ * Drop indicators for manual reordering...
+ */
+#if 0
+ if (!m_lastDragPosition.isNull())
+ {
+ QPair<Group *, int> pair = rowDropPos(m_lastDragPosition);
+ Group *category = pair.first;
+ int row = pair.second;
+ if (category)
+ {
+ int internalRow = row - category->firstItemIndex;
+ QLine line;
+ if (internalRow >= category->numItems())
+ {
+ QRect toTheRightOfRect = visualRect(category->lastItem());
+ line = QLine(toTheRightOfRect.topRight(), toTheRightOfRect.bottomRight());
+ }
+ else
+ {
+ QRect toTheLeftOfRect = visualRect(model()->index(row, 0));
+ line = QLine(toTheLeftOfRect.topLeft(), toTheLeftOfRect.bottomLeft());
+ }
+ painter.save();
+ painter.setPen(QPen(Qt::black, 3));
+ painter.drawLine(line);
+ painter.restore();
+ }
+ }
+#endif
+}
+
+void InstanceView::resizeEvent(QResizeEvent* event)
+{
+ int newItemsPerRow = calculateItemsPerRow();
+ if (newItemsPerRow != m_currentItemsPerRow) {
+ m_currentCursorColumn = -1;
+ m_currentItemsPerRow = newItemsPerRow;
+ updateGeometries();
+ } else {
+ updateScrollbar();
+ }
+}
+
+void InstanceView::dragEnterEvent(QDragEnterEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ if (!isDragEventAccepted(event)) {
+ return;
+ }
+ m_lastDragPosition = event->pos() + offset();
+ viewport()->update();
+ event->accept();
+}
+
+void InstanceView::dragMoveEvent(QDragMoveEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ if (!isDragEventAccepted(event)) {
+ return;
+ }
+ m_lastDragPosition = event->pos() + offset();
+ viewport()->update();
+ event->accept();
+}
+
+void InstanceView::dragLeaveEvent(QDragLeaveEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ m_lastDragPosition = QPoint();
+ viewport()->update();
+}
+
+void InstanceView::dropEvent(QDropEvent* event)
+{
+ executeDelayedItemsLayout();
+
+ m_lastDragPosition = QPoint();
+
+ stopAutoScroll();
+ setState(NoState);
+
+ auto mimedata = event->mimeData();
+
+ if (event->source() == this) {
+ if (event->possibleActions() & Qt::MoveAction) {
+ QPair<VisualGroup*, VisualGroup::HitResults> dropPos =
+ rowDropPos(event->pos());
+ const VisualGroup* group = dropPos.first;
+ auto hitresult = dropPos.second;
+
+ if (hitresult == VisualGroup::HitResult::NoHit) {
+ viewport()->update();
+ return;
+ }
+ auto instanceId =
+ QString::fromUtf8(mimedata->data("application/x-instanceid"));
+ auto instanceList = APPLICATION->instances().get();
+ instanceList->setInstanceGroup(instanceId, group->text);
+ event->setDropAction(Qt::MoveAction);
+ event->accept();
+
+ updateGeometries();
+ viewport()->update();
+ }
+ return;
+ }
+
+ // check if the action is supported
+ if (!mimedata) {
+ return;
+ }
+
+ // files dropped from outside?
+ if (mimedata->hasUrls()) {
+ auto urls = mimedata->urls();
+ event->accept();
+ emit droppedURLs(urls);
+ }
+}
+
+void InstanceView::startDrag(Qt::DropActions supportedActions)
+{
+ executeDelayedItemsLayout();
+
+ QModelIndexList indexes = selectionModel()->selectedIndexes();
+ if (indexes.count() == 0)
+ return;
+
+ QMimeData* data = model()->mimeData(indexes);
+ if (!data) {
+ return;
+ }
+ QRect rect;
+ QPixmap pixmap = renderToPixmap(indexes, &rect);
+ QDrag* drag = new QDrag(this);
+ drag->setPixmap(pixmap);
+ drag->setMimeData(data);
+ drag->setHotSpot(m_pressedPosition - rect.topLeft());
+ Qt::DropAction defaultDropAction = Qt::IgnoreAction;
+ if (this->defaultDropAction() != Qt::IgnoreAction &&
+ (supportedActions & this->defaultDropAction())) {
+ defaultDropAction = this->defaultDropAction();
+ }
+ /*auto action = */
+ drag->exec(supportedActions, defaultDropAction);
+}
+
+QRect InstanceView::visualRect(const QModelIndex& index) const
+{
+ const_cast<InstanceView*>(this)->executeDelayedItemsLayout();
+
+ return geometryRect(index).translated(-offset());
+}
+
+QRect InstanceView::geometryRect(const QModelIndex& index) const
+{
+ const_cast<InstanceView*>(this)->executeDelayedItemsLayout();
+
+ if (!index.isValid() || isIndexHidden(index) || index.column() > 0) {
+ return QRect();
+ }
+
+ int row = index.row();
+ if (geometryCache.contains(row)) {
+ return *geometryCache[row];
+ }
+
+ const VisualGroup* cat = category(index);
+ QPair<int, int> pos = cat->positionOf(index);
+ int x = pos.first;
+ // int y = pos.second;
+
+ QRect out;
+ out.setTop(cat->verticalPosition() + cat->headerHeight() + 5 +
+ cat->rowTopOf(index));
+ out.setLeft(m_spacing + x * (itemWidth() + m_spacing));
+ out.setSize(itemDelegate()->sizeHint(viewOptions(), index));
+ geometryCache.insert(row, new QRect(out));
+ return out;
+}
+
+QModelIndex InstanceView::indexAt(const QPoint& point) const
+{
+ const_cast<InstanceView*>(this)->executeDelayedItemsLayout();
+
+ for (int i = 0; i < model()->rowCount(); ++i) {
+ QModelIndex index = model()->index(i, 0);
+ if (visualRect(index).contains(point)) {
+ return index;
+ }
+ }
+ return QModelIndex();
+}
+
+void InstanceView::setSelection(
+ const QRect& rect, const QItemSelectionModel::SelectionFlags commands)
+{
+ executeDelayedItemsLayout();
+
+ for (int i = 0; i < model()->rowCount(); ++i) {
+ QModelIndex index = model()->index(i, 0);
+ QRect itemRect = visualRect(index);
+ if (itemRect.intersects(rect)) {
+ selectionModel()->select(index, commands);
+ update(itemRect.translated(-offset()));
+ }
+ }
+}
+
+QPixmap InstanceView::renderToPixmap(const QModelIndexList& indices,
+ QRect* r) const
+{
+ Q_ASSERT(r);
+ auto paintPairs = draggablePaintPairs(indices, r);
+ if (paintPairs.isEmpty()) {
+ return QPixmap();
+ }
+ QPixmap pixmap(r->size());
+ pixmap.fill(Qt::transparent);
+ QPainter painter(&pixmap);
+ QStyleOptionViewItem option = viewOptions();
+ option.state |= QStyle::State_Selected;
+ for (int j = 0; j < paintPairs.count(); ++j) {
+ option.rect = paintPairs.at(j).first.translated(-r->topLeft());
+ const QModelIndex& current = paintPairs.at(j).second;
+ itemDelegate()->paint(&painter, option, current);
+ }
+ return pixmap;
+}
+
+QList<QPair<QRect, QModelIndex>>
+InstanceView::draggablePaintPairs(const QModelIndexList& indices,
+ QRect* r) const
+{
+ Q_ASSERT(r);
+ QRect& rect = *r;
+ QList<QPair<QRect, QModelIndex>> ret;
+ for (int i = 0; i < indices.count(); ++i) {
+ const QModelIndex& index = indices.at(i);
+ const QRect current = geometryRect(index);
+ ret += qMakePair(current, index);
+ rect |= current;
+ }
+ return ret;
+}
+
+bool InstanceView::isDragEventAccepted(QDropEvent* event)
+{
+ return true;
+}
+
+QPair<VisualGroup*, VisualGroup::HitResults>
+InstanceView::rowDropPos(const QPoint& pos)
+{
+ VisualGroup::HitResults hitresult;
+ auto group = categoryAt(pos + offset(), hitresult);
+ return qMakePair(group, hitresult);
+}
+
+QPoint InstanceView::offset() const
+{
+ return QPoint(horizontalOffset(), verticalOffset());
+}
+
+QRegion
+InstanceView::visualRegionForSelection(const QItemSelection& selection) const
+{
+ QRegion region;
+ for (auto& range : selection) {
+ int start_row = range.top();
+ int end_row = range.bottom();
+ for (int row = start_row; row <= end_row; ++row) {
+ int start_column = range.left();
+ int end_column = range.right();
+ for (int column = start_column; column <= end_column; ++column) {
+ QModelIndex index = model()->index(row, column, rootIndex());
+ region += visualRect(index); // OK
+ }
+ }
+ }
+ return region;
+}
+
+QModelIndex
+InstanceView::moveCursor(QAbstractItemView::CursorAction cursorAction,
+ Qt::KeyboardModifiers modifiers)
+{
+ auto current = currentIndex();
+ if (!current.isValid()) {
+ return current;
+ }
+ auto cat = category(current);
+ int group_index = m_groups.indexOf(cat);
+ if (group_index < 0)
+ return current;
+
+ QPair<int, int> pos = cat->positionOf(current);
+ int column = pos.first;
+ int row = pos.second;
+ if (m_currentCursorColumn < 0) {
+ m_currentCursorColumn = column;
+ }
+ switch (cursorAction) {
+ case MoveUp: {
+ if (row == 0) {
+ int prevgroupindex = group_index - 1;
+ while (prevgroupindex >= 0) {
+ auto prevgroup = m_groups[prevgroupindex];
+ if (prevgroup->collapsed) {
+ prevgroupindex--;
+ continue;
+ }
+ int newRow = prevgroup->numRows() - 1;
+ int newRowSize = prevgroup->rows[newRow].size();
+ int newColumn = m_currentCursorColumn;
+ if (m_currentCursorColumn >= newRowSize) {
+ newColumn = newRowSize - 1;
+ }
+ return prevgroup->rows[newRow][newColumn];
+ }
+ } else {
+ int newRow = row - 1;
+ int newRowSize = cat->rows[newRow].size();
+ int newColumn = m_currentCursorColumn;
+ if (m_currentCursorColumn >= newRowSize) {
+ newColumn = newRowSize - 1;
+ }
+ return cat->rows[newRow][newColumn];
+ }
+ return current;
+ }
+ case MoveDown: {
+ if (row == cat->rows.size() - 1) {
+ int nextgroupindex = group_index + 1;
+ while (nextgroupindex < m_groups.size()) {
+ auto nextgroup = m_groups[nextgroupindex];
+ if (nextgroup->collapsed) {
+ nextgroupindex++;
+ continue;
+ }
+ int newRowSize = nextgroup->rows[0].size();
+ int newColumn = m_currentCursorColumn;
+ if (m_currentCursorColumn >= newRowSize) {
+ newColumn = newRowSize - 1;
+ }
+ return nextgroup->rows[0][newColumn];
+ }
+ } else {
+ int newRow = row + 1;
+ int newRowSize = cat->rows[newRow].size();
+ int newColumn = m_currentCursorColumn;
+ if (m_currentCursorColumn >= newRowSize) {
+ newColumn = newRowSize - 1;
+ }
+ return cat->rows[newRow][newColumn];
+ }
+ return current;
+ }
+ case MoveLeft: {
+ if (column > 0) {
+ m_currentCursorColumn = column - 1;
+ return cat->rows[row][column - 1];
+ }
+ // TODO: moving to previous line
+ return current;
+ }
+ case MoveRight: {
+ if (column < cat->rows[row].size() - 1) {
+ m_currentCursorColumn = column + 1;
+ return cat->rows[row][column + 1];
+ }
+ // TODO: moving to next line
+ return current;
+ }
+ case MoveHome: {
+ m_currentCursorColumn = 0;
+ return cat->rows[row][0];
+ }
+ case MoveEnd: {
+ auto last = cat->rows[row].size() - 1;
+ m_currentCursorColumn = last;
+ return cat->rows[row][last];
+ }
+ default:
+ break;
+ }
+ return current;
+}
+
+int InstanceView::horizontalOffset() const
+{
+ return horizontalScrollBar()->value();
+}
+
+int InstanceView::verticalOffset() const
+{
+ return verticalScrollBar()->value();
+}
+
+void InstanceView::scrollContentsBy(int dx, int dy)
+{
+ scrollDirtyRegion(dx, dy);
+ viewport()->scroll(dx, dy);
+}
+
+void InstanceView::scrollTo(const QModelIndex& index, ScrollHint hint)
+{
+ if (!index.isValid())
+ return;
+
+ const QRect rect = visualRect(index);
+ if (hint == EnsureVisible && viewport()->rect().contains(rect)) {
+ viewport()->update(rect);
+ return;
+ }
+
+ verticalScrollBar()->setValue(verticalScrollToValue(index, rect, hint));
+}
+
+int InstanceView::verticalScrollToValue(const QModelIndex& index,
+ const QRect& rect,
+ QListView::ScrollHint hint) const
+{
+ const QRect area = viewport()->rect();
+ const bool above =
+ (hint == QListView::EnsureVisible && rect.top() < area.top());
+ const bool below =
+ (hint == QListView::EnsureVisible && rect.bottom() > area.bottom());
+
+ int verticalValue = verticalScrollBar()->value();
+ QRect adjusted =
+ rect.adjusted(-spacing(), -spacing(), spacing(), spacing());
+ if (hint == QListView::PositionAtTop || above)
+ verticalValue += adjusted.top();
+ else if (hint == QListView::PositionAtBottom || below)
+ verticalValue +=
+ qMin(adjusted.top(), adjusted.bottom() - area.height() + 1);
+ else if (hint == QListView::PositionAtCenter)
+ verticalValue +=
+ adjusted.top() - ((area.height() - adjusted.height()) / 2);
+ return verticalValue;
+}
diff --git a/meshmc/launcher/ui/instanceview/InstanceView.h b/meshmc/launcher/ui/instanceview/InstanceView.h
new file mode 100644
index 0000000000..5eb3f78d98
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/InstanceView.h
@@ -0,0 +1,192 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QListView>
+#include <QLineEdit>
+#include <QScrollBar>
+#include <QCache>
+#include "VisualGroup.h"
+#include <functional>
+
+struct InstanceViewRoles {
+ enum { GroupRole = Qt::UserRole, ProgressValueRole, ProgressMaximumRole };
+};
+
+class InstanceView : public QAbstractItemView
+{
+ Q_OBJECT
+
+ public:
+ InstanceView(QWidget* parent = 0);
+ ~InstanceView();
+
+ QStyleOptionViewItem viewOptions() const
+ {
+ QStyleOptionViewItem option;
+ initViewItemOption(&option);
+ return option;
+ }
+
+ void setModel(QAbstractItemModel* model) override;
+
+ using visibilityFunction = std::function<bool(const QString&)>;
+ void setSourceOfGroupCollapseStatus(visibilityFunction f)
+ {
+ fVisibility = f;
+ }
+
+ /// return geometry rectangle occupied by the specified model item
+ QRect geometryRect(const QModelIndex& index) const;
+ /// return visual rectangle occupied by the specified model item
+ virtual QRect visualRect(const QModelIndex& index) const override;
+ /// get the model index at the specified visual point
+ virtual QModelIndex indexAt(const QPoint& point) const override;
+ QString groupNameAt(const QPoint& point);
+ void
+ setSelection(const QRect& rect,
+ const QItemSelectionModel::SelectionFlags commands) override;
+
+ virtual int horizontalOffset() const override;
+ virtual int verticalOffset() const override;
+ virtual void scrollContentsBy(int dx, int dy) override;
+ virtual void scrollTo(const QModelIndex& index,
+ ScrollHint hint = EnsureVisible) override;
+
+ virtual QModelIndex moveCursor(CursorAction cursorAction,
+ Qt::KeyboardModifiers modifiers) override;
+
+ virtual QRegion
+ visualRegionForSelection(const QItemSelection& selection) const override;
+
+ int spacing() const
+ {
+ return m_spacing;
+ };
+
+ public slots:
+ virtual void updateGeometries() override;
+
+ protected slots:
+ virtual void dataChanged(const QModelIndex& topLeft,
+ const QModelIndex& bottomRight,
+ const QVector<int>& roles) override;
+ virtual void rowsInserted(const QModelIndex& parent, int start,
+ int end) override;
+ virtual void rowsAboutToBeRemoved(const QModelIndex& parent, int start,
+ int end) override;
+ void modelReset();
+ void rowsRemoved();
+ void currentChanged(const QModelIndex& current,
+ const QModelIndex& previous) override;
+
+ signals:
+ void droppedURLs(QList<QUrl> urls);
+ void groupStateChanged(QString group, bool collapsed);
+
+ protected:
+ bool isIndexHidden(const QModelIndex& index) const override;
+ void mousePressEvent(QMouseEvent* event) override;
+ void mouseMoveEvent(QMouseEvent* event) override;
+ void mouseReleaseEvent(QMouseEvent* event) override;
+ void mouseDoubleClickEvent(QMouseEvent* event) override;
+ void paintEvent(QPaintEvent* event) override;
+ void resizeEvent(QResizeEvent* event) override;
+
+ void dragEnterEvent(QDragEnterEvent* event) override;
+ void dragMoveEvent(QDragMoveEvent* event) override;
+ void dragLeaveEvent(QDragLeaveEvent* event) override;
+ void dropEvent(QDropEvent* event) override;
+
+ void startDrag(Qt::DropActions supportedActions) override;
+
+ void updateScrollbar();
+
+ private:
+ friend struct VisualGroup;
+ QList<VisualGroup*> m_groups;
+
+ visibilityFunction fVisibility;
+
+ // geometry
+ int m_leftMargin = 5;
+ int m_rightMargin = 5;
+ int m_bottomMargin = 5;
+ int m_categoryMargin = 5;
+ int m_spacing = 5;
+ int m_itemWidth = 100;
+ int m_currentItemsPerRow = -1;
+ int m_currentCursorColumn = -1;
+ mutable QCache<int, QRect> geometryCache;
+
+ // point where the currently active mouse action started in geometry
+ // coordinates
+ QPoint m_pressedPosition;
+ QPersistentModelIndex m_pressedIndex;
+ bool m_pressedAlreadySelected;
+ VisualGroup* m_pressedCategory;
+ QItemSelectionModel::SelectionFlag m_ctrlDragSelectionFlag;
+ QPoint m_lastDragPosition;
+
+ VisualGroup* category(const QModelIndex& index) const;
+ VisualGroup* category(const QString& cat) const;
+ VisualGroup* categoryAt(const QPoint& pos,
+ VisualGroup::HitResults& result) const;
+
+ int itemsPerRow() const
+ {
+ return m_currentItemsPerRow;
+ };
+ int contentWidth() const;
+
+ private: /* methods */
+ int itemWidth() const;
+ int calculateItemsPerRow() const;
+ int verticalScrollToValue(const QModelIndex& index, const QRect& rect,
+ QListView::ScrollHint hint) const;
+ QPixmap renderToPixmap(const QModelIndexList& indices, QRect* r) const;
+ QList<QPair<QRect, QModelIndex>>
+ draggablePaintPairs(const QModelIndexList& indices, QRect* r) const;
+
+ bool isDragEventAccepted(QDropEvent* event);
+
+ QPair<VisualGroup*, VisualGroup::HitResults> rowDropPos(const QPoint& pos);
+
+ QPoint offset() const;
+};
diff --git a/meshmc/launcher/ui/instanceview/VisualGroup.cpp b/meshmc/launcher/ui/instanceview/VisualGroup.cpp
new file mode 100644
index 0000000000..aab6adc1b6
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/VisualGroup.cpp
@@ -0,0 +1,334 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "VisualGroup.h"
+
+#include <QModelIndex>
+#include <QPainter>
+#include <QtMath>
+#include <QApplication>
+#include <QDebug>
+
+#include "InstanceView.h"
+
+VisualGroup::VisualGroup(const QString& text, InstanceView* view)
+ : view(view), text(text), collapsed(false)
+{
+}
+
+VisualGroup::VisualGroup(const VisualGroup* other)
+ : view(other->view), text(other->text), collapsed(other->collapsed)
+{
+}
+
+void VisualGroup::update()
+{
+ auto temp_items = items();
+ auto itemsPerRow = view->itemsPerRow();
+
+ int numRows = qMax(1, qCeil((qreal)temp_items.size() / (qreal)itemsPerRow));
+ rows = QVector<VisualRow>(numRows);
+
+ int maxRowHeight = 0;
+ int positionInRow = 0;
+ int currentRow = 0;
+ int offsetFromTop = 0;
+ for (auto item : temp_items) {
+ if (positionInRow == itemsPerRow) {
+ rows[currentRow].height = maxRowHeight;
+ rows[currentRow].top = offsetFromTop;
+ currentRow++;
+ offsetFromTop += maxRowHeight + 5;
+ positionInRow = 0;
+ maxRowHeight = 0;
+ }
+ auto itemHeight =
+ view->itemDelegate()->sizeHint(view->viewOptions(), item).height();
+ if (itemHeight > maxRowHeight) {
+ maxRowHeight = itemHeight;
+ }
+ rows[currentRow].items.append(item);
+ positionInRow++;
+ }
+ rows[currentRow].height = maxRowHeight;
+ rows[currentRow].top = offsetFromTop;
+}
+
+QPair<int, int> VisualGroup::positionOf(const QModelIndex& index) const
+{
+ int y = 0;
+ for (auto& row : rows) {
+ for (auto x = 0; x < row.items.size(); x++) {
+ if (row.items[x] == index) {
+ return qMakePair(x, y);
+ }
+ }
+ y++;
+ }
+ qWarning() << "Item" << index.row()
+ << index.data(Qt::DisplayRole).toString()
+ << "not found in visual group" << text;
+ return qMakePair(0, 0);
+}
+
+int VisualGroup::rowTopOf(const QModelIndex& index) const
+{
+ auto position = positionOf(index);
+ return rows[position.second].top;
+}
+
+int VisualGroup::rowHeightOf(const QModelIndex& index) const
+{
+ auto position = positionOf(index);
+ return rows[position.second].height;
+}
+
+VisualGroup::HitResults VisualGroup::hitScan(const QPoint& pos) const
+{
+ VisualGroup::HitResults results = VisualGroup::NoHit;
+ int y_start = verticalPosition();
+ int body_start = y_start + headerHeight();
+ int body_end = body_start + contentHeight() + 5; // FIXME: wtf is this 5?
+ int y = pos.y();
+ // int x = pos.x();
+ if (y < y_start) {
+ results = VisualGroup::NoHit;
+ } else if (y < body_start) {
+ results = VisualGroup::HeaderHit;
+ int collapseSize = headerHeight() - 4;
+
+ // the icon
+ QRect iconRect = QRect(view->m_leftMargin + 2, 2 + y_start,
+ collapseSize, collapseSize);
+ if (iconRect.contains(pos)) {
+ results |= VisualGroup::CheckboxHit;
+ }
+ } else if (y < body_end) {
+ results |= VisualGroup::BodyHit;
+ }
+ return results;
+}
+
+void VisualGroup::drawHeader(QPainter* painter,
+ const QStyleOptionViewItem& option)
+{
+ painter->setRenderHint(QPainter::Antialiasing);
+
+ const QRect optRect = option.rect;
+ QFont font(QApplication::font());
+ font.setBold(true);
+ const QFontMetrics fontMetrics = QFontMetrics(font);
+
+ QColor outlineColor = option.palette.text().color();
+ outlineColor.setAlphaF(0.35);
+
+ // BEGIN: top left corner
+ {
+ painter->save();
+ painter->setPen(outlineColor);
+ const QPointF topLeft(optRect.topLeft());
+ QRectF arc(topLeft, QSizeF(4, 4));
+ arc.translate(0.5, 0.5);
+ painter->drawArc(arc, 1440, 1440);
+ painter->restore();
+ }
+ // END: top left corner
+
+ // BEGIN: left vertical line
+ {
+ QPoint start(optRect.topLeft());
+ start.ry() += 3;
+ QPoint verticalGradBottom(optRect.topLeft());
+ verticalGradBottom.ry() += fontMetrics.height() + 5;
+ QLinearGradient gradient(start, verticalGradBottom);
+ gradient.setColorAt(0, outlineColor);
+ gradient.setColorAt(1, Qt::transparent);
+ painter->fillRect(QRect(start, QSize(1, fontMetrics.height() + 5)),
+ gradient);
+ }
+ // END: left vertical line
+
+ // BEGIN: horizontal line
+ {
+ QPoint start(optRect.topLeft());
+ start.rx() += 3;
+ QPoint horizontalGradTop(optRect.topLeft());
+ horizontalGradTop.rx() += optRect.width() - 6;
+ painter->fillRect(QRect(start, QSize(optRect.width() - 6, 1)),
+ outlineColor);
+ }
+ // END: horizontal line
+
+ // BEGIN: top right corner
+ {
+ painter->save();
+ painter->setPen(outlineColor);
+ QPointF topRight(optRect.topRight());
+ topRight.rx() -= 4;
+ QRectF arc(topRight, QSizeF(4, 4));
+ arc.translate(0.5, 0.5);
+ painter->drawArc(arc, 0, 1440);
+ painter->restore();
+ }
+ // END: top right corner
+
+ // BEGIN: right vertical line
+ {
+ QPoint start(optRect.topRight());
+ start.ry() += 3;
+ QPoint verticalGradBottom(optRect.topRight());
+ verticalGradBottom.ry() += fontMetrics.height() + 5;
+ QLinearGradient gradient(start, verticalGradBottom);
+ gradient.setColorAt(0, outlineColor);
+ gradient.setColorAt(1, Qt::transparent);
+ painter->fillRect(QRect(start, QSize(1, fontMetrics.height() + 5)),
+ gradient);
+ }
+ // END: right vertical line
+
+ // BEGIN: checkboxy thing
+ {
+ painter->save();
+ painter->setRenderHint(QPainter::Antialiasing, false);
+ painter->setFont(font);
+ QColor penColor(option.palette.text().color());
+ penColor.setAlphaF(0.6);
+ painter->setPen(penColor);
+ QRect iconSubRect(option.rect);
+ iconSubRect.setTop(iconSubRect.top() + 7);
+ iconSubRect.setLeft(iconSubRect.left() + 7);
+
+ int sizing = fontMetrics.height();
+ int even = ((sizing - 1) % 2);
+
+ iconSubRect.setHeight(sizing - even);
+ iconSubRect.setWidth(sizing - even);
+ painter->drawRect(iconSubRect);
+
+ /*
+ if(collapsed)
+ painter->drawText(iconSubRect, Qt::AlignHCenter | Qt::AlignVCenter,
+ "+"); else painter->drawText(iconSubRect, Qt::AlignHCenter |
+ Qt::AlignVCenter, "-");
+ */
+ painter->setBrush(option.palette.text());
+ painter->fillRect(iconSubRect.x(),
+ iconSubRect.y() + iconSubRect.height() / 2,
+ iconSubRect.width(), 2, penColor);
+ if (collapsed) {
+ painter->fillRect(iconSubRect.x() + iconSubRect.width() / 2,
+ iconSubRect.y(), 2, iconSubRect.height(),
+ penColor);
+ }
+
+ painter->restore();
+ }
+ // END: checkboxy thing
+
+ // BEGIN: text
+ {
+ QRect textRect(option.rect);
+ textRect.setTop(textRect.top() + 7);
+ textRect.setLeft(textRect.left() + 7 + fontMetrics.height() + 7);
+ textRect.setHeight(fontMetrics.height());
+ textRect.setRight(textRect.right() - 7);
+
+ painter->save();
+ painter->setFont(font);
+ QColor penColor(option.palette.text().color());
+ penColor.setAlphaF(0.6);
+ painter->setPen(penColor);
+ painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, text);
+ painter->restore();
+ }
+ // END: text
+}
+
+int VisualGroup::totalHeight() const
+{
+ return headerHeight() + 5 + contentHeight(); // FIXME: wtf is that '5'?
+}
+
+int VisualGroup::headerHeight() const
+{
+ QFont font(QApplication::font());
+ font.setBold(true);
+ QFontMetrics fontMetrics(font);
+
+ const int height = fontMetrics.height() + 1 /* 1 pixel-width gradient */
+ + 11 /* top and bottom separation */;
+ return height;
+ /*
+ int raw = view->viewport()->fontMetrics().height() + 4;
+ // add english. maybe. depends on font height.
+ if (raw % 2 == 0)
+ raw++;
+ return std::min(raw, 25);
+ */
+}
+
+int VisualGroup::contentHeight() const
+{
+ if (collapsed) {
+ return 0;
+ }
+ auto last = rows[numRows() - 1];
+ return last.top + last.height;
+}
+
+int VisualGroup::numRows() const
+{
+ return rows.size();
+}
+
+int VisualGroup::verticalPosition() const
+{
+ return m_verticalPosition;
+}
+
+QList<QModelIndex> VisualGroup::items() const
+{
+ QList<QModelIndex> indices;
+ for (int i = 0; i < view->model()->rowCount(); ++i) {
+ const QModelIndex index = view->model()->index(i, 0);
+ if (index.data(InstanceViewRoles::GroupRole).toString() == text) {
+ indices.append(index);
+ }
+ }
+ return indices;
+}
diff --git a/meshmc/launcher/ui/instanceview/VisualGroup.h b/meshmc/launcher/ui/instanceview/VisualGroup.h
new file mode 100644
index 0000000000..9ef7771d03
--- /dev/null
+++ b/meshmc/launcher/ui/instanceview/VisualGroup.h
@@ -0,0 +1,126 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QString>
+#include <QRect>
+#include <QVector>
+#include <QStyleOption>
+
+class InstanceView;
+class QPainter;
+class QModelIndex;
+
+struct VisualRow {
+ QList<QModelIndex> items;
+ int height = 0;
+ int top = 0;
+ inline int size() const
+ {
+ return items.size();
+ }
+ inline QModelIndex& operator[](int i)
+ {
+ return items[i];
+ }
+};
+
+struct VisualGroup {
+ /* constructors */
+ VisualGroup(const QString& text, InstanceView* view);
+ VisualGroup(const VisualGroup* other);
+
+ /* data */
+ InstanceView* view = nullptr;
+ QString text;
+ bool collapsed = false;
+ QVector<VisualRow> rows;
+ int firstItemIndex = 0;
+ int m_verticalPosition = 0;
+
+ /* logic */
+ /// update the internal list of items and flow them into the rows.
+ void update();
+
+ /// draw the header at y-position.
+ void drawHeader(QPainter* painter, const QStyleOptionViewItem& option);
+
+ /// height of the group, in total. includes a small bit of padding.
+ int totalHeight() const;
+
+ /// height of the group header, in pixels
+ int headerHeight() const;
+
+ /// height of the group content, in pixels
+ int contentHeight() const;
+
+ /// the number of visual rows this group has
+ int numRows() const;
+
+ /// actually calculate the above value
+ int calculateNumRows() const;
+
+ /// the height at which this group starts, in pixels
+ int verticalPosition() const;
+
+ /// relative geometry - top of the row of the given item
+ int rowTopOf(const QModelIndex& index) const;
+
+ /// height of the row of the given item
+ int rowHeightOf(const QModelIndex& index) const;
+
+ /// x/y position of the given item inside the group (in items!)
+ QPair<int, int> positionOf(const QModelIndex& index) const;
+
+ enum HitResult {
+ NoHit = 0x0,
+ TextHit = 0x1,
+ CheckboxHit = 0x2,
+ HeaderHit = 0x4,
+ BodyHit = 0x8
+ };
+ Q_DECLARE_FLAGS(HitResults, HitResult)
+
+ /// shoot! BANG! what did we hit?
+ HitResults hitScan(const QPoint& pos) const;
+
+ QList<QModelIndex> items() const;
+};
+
+Q_DECLARE_OPERATORS_FOR_FLAGS(VisualGroup::HitResults)
diff --git a/meshmc/launcher/ui/pagedialog/PageDialog.cpp b/meshmc/launcher/ui/pagedialog/PageDialog.cpp
new file mode 100644
index 0000000000..f685e1a2cb
--- /dev/null
+++ b/meshmc/launcher/ui/pagedialog/PageDialog.cpp
@@ -0,0 +1,90 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "PageDialog.h"
+
+#include <QDialogButtonBox>
+#include <QPushButton>
+#include <QVBoxLayout>
+#include <QKeyEvent>
+
+#include "Application.h"
+#include "settings/SettingsObject.h"
+
+#include "ui/widgets/IconLabel.h"
+#include "ui/widgets/PageContainer.h"
+
+PageDialog::PageDialog(BasePageProvider* pageProvider, QString defaultId,
+ QWidget* parent)
+ : QDialog(parent)
+{
+ setWindowTitle(pageProvider->dialogTitle());
+ m_container = new PageContainer(pageProvider, defaultId, this);
+
+ QVBoxLayout* mainLayout = new QVBoxLayout;
+ mainLayout->addWidget(m_container);
+ mainLayout->setSpacing(0);
+ mainLayout->setContentsMargins(0, 0, 0, 0);
+ setLayout(mainLayout);
+
+ QDialogButtonBox* buttons =
+ new QDialogButtonBox(QDialogButtonBox::Help | QDialogButtonBox::Close);
+ buttons->button(QDialogButtonBox::Close)->setDefault(true);
+ buttons->setContentsMargins(6, 0, 6, 0);
+ m_container->addButtons(buttons);
+
+ connect(buttons->button(QDialogButtonBox::Close), SIGNAL(clicked()), this,
+ SLOT(close()));
+ connect(buttons->button(QDialogButtonBox::Help), SIGNAL(clicked()),
+ m_container, SLOT(help()));
+
+ restoreGeometry(QByteArray::fromBase64(
+ APPLICATION->settings()->get("PagedGeometry").toByteArray()));
+}
+
+void PageDialog::closeEvent(QCloseEvent* event)
+{
+ qDebug() << "Paged dialog close requested";
+ if (m_container->prepareToClose()) {
+ qDebug() << "Paged dialog close approved";
+ APPLICATION->settings()->set("PagedGeometry",
+ saveGeometry().toBase64());
+ qDebug() << "Paged dialog geometry saved";
+ QDialog::closeEvent(event);
+ }
+}
diff --git a/meshmc/launcher/ui/pagedialog/PageDialog.h b/meshmc/launcher/ui/pagedialog/PageDialog.h
new file mode 100644
index 0000000000..f26c63b7e0
--- /dev/null
+++ b/meshmc/launcher/ui/pagedialog/PageDialog.h
@@ -0,0 +1,58 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include "ui/pages/BasePageProvider.h"
+
+class PageContainer;
+class PageDialog : public QDialog
+{
+ Q_OBJECT
+ public:
+ explicit PageDialog(BasePageProvider* pageProvider,
+ QString defaultId = QString(), QWidget* parent = 0);
+ virtual ~PageDialog() {}
+
+ private slots:
+ virtual void closeEvent(QCloseEvent* event);
+
+ private:
+ PageContainer* m_container;
+};
diff --git a/meshmc/launcher/ui/pages/BasePage.h b/meshmc/launcher/ui/pages/BasePage.h
new file mode 100644
index 0000000000..5d7d42f9eb
--- /dev/null
+++ b/meshmc/launcher/ui/pages/BasePage.h
@@ -0,0 +1,92 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QString>
+#include <QIcon>
+#include <memory>
+
+#include "BasePageContainer.h"
+
+class BasePage
+{
+ public:
+ virtual ~BasePage() {}
+ virtual QString id() const = 0;
+ virtual QString displayName() const = 0;
+ virtual QIcon icon() const = 0;
+ virtual bool apply()
+ {
+ return true;
+ }
+ virtual bool shouldDisplay() const
+ {
+ return true;
+ }
+ virtual QString helpPage() const
+ {
+ return QString();
+ }
+ void opened()
+ {
+ isOpened = true;
+ openedImpl();
+ }
+ void closed()
+ {
+ isOpened = false;
+ closedImpl();
+ }
+ virtual void openedImpl() {}
+ virtual void closedImpl() {}
+ virtual void setParentContainer(BasePageContainer* container)
+ {
+ m_container = container;
+ };
+
+ public:
+ int stackIndex = -1;
+ int listIndex = -1;
+
+ protected:
+ BasePageContainer* m_container = nullptr;
+ bool isOpened = false;
+};
+
+typedef std::shared_ptr<BasePage> BasePagePtr;
diff --git a/meshmc/launcher/ui/pages/BasePageContainer.h b/meshmc/launcher/ui/pages/BasePageContainer.h
new file mode 100644
index 0000000000..7c32ce3050
--- /dev/null
+++ b/meshmc/launcher/ui/pages/BasePageContainer.h
@@ -0,0 +1,31 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+class BasePageContainer
+{
+ public:
+ virtual ~BasePageContainer() {};
+ virtual bool selectPage(QString pageId) = 0;
+ virtual void refreshContainer() = 0;
+ virtual bool requestClose() = 0;
+};
diff --git a/meshmc/launcher/ui/pages/BasePageProvider.h b/meshmc/launcher/ui/pages/BasePageProvider.h
new file mode 100644
index 0000000000..70ab612366
--- /dev/null
+++ b/meshmc/launcher/ui/pages/BasePageProvider.h
@@ -0,0 +1,93 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include "ui/pages/BasePage.h"
+#include <memory>
+#include <functional>
+
+class BasePageProvider
+{
+ public:
+ virtual QList<BasePage*> getPages() = 0;
+ virtual QString dialogTitle() = 0;
+};
+
+class GenericPageProvider : public BasePageProvider
+{
+ typedef std::function<BasePage*()> PageCreator;
+
+ public:
+ explicit GenericPageProvider(const QString& dialogTitle)
+ : m_dialogTitle(dialogTitle)
+ {
+ }
+ virtual ~GenericPageProvider() {}
+
+ QList<BasePage*> getPages() override
+ {
+ QList<BasePage*> pages;
+ for (PageCreator creator : m_creators) {
+ pages.append(creator());
+ }
+ return pages;
+ }
+ QString dialogTitle() override
+ {
+ return m_dialogTitle;
+ }
+
+ void setDialogTitle(const QString& title)
+ {
+ m_dialogTitle = title;
+ }
+ void addPageCreator(PageCreator page)
+ {
+ m_creators.append(page);
+ }
+
+ template <typename PageClass> void addPage()
+ {
+ addPageCreator([]() { return new PageClass(); });
+ }
+
+ private:
+ QList<PageCreator> m_creators;
+ QString m_dialogTitle;
+};
diff --git a/meshmc/launcher/ui/pages/global/AccountListPage.cpp b/meshmc/launcher/ui/pages/global/AccountListPage.cpp
new file mode 100644
index 0000000000..520877a664
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/AccountListPage.cpp
@@ -0,0 +1,261 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "AccountListPage.h"
+#include "ui_AccountListPage.h"
+
+#include <QItemSelectionModel>
+#include <QMenu>
+
+#include <QDebug>
+
+#include "net/NetJob.h"
+
+#include "ui/dialogs/ProgressDialog.h"
+#include "ui/dialogs/MSALoginDialog.h"
+#include "ui/dialogs/CustomMessageBox.h"
+#include "ui/dialogs/SkinUploadDialog.h"
+
+#include "tasks/Task.h"
+#include "minecraft/auth/AccountTask.h"
+#include "minecraft/services/SkinDelete.h"
+
+#include "Application.h"
+
+#include "BuildConfig.h"
+
+AccountListPage::AccountListPage(QWidget* parent)
+ : QMainWindow(parent), ui(new Ui::AccountListPage)
+{
+ ui->setupUi(this);
+ ui->listView->setEmptyString(
+ tr("Welcome!\n"
+ "If you're new here, you can click the \"Add Microsoft\" button to "
+ "add your Microsoft account."));
+ ui->listView->setEmptyMode(VersionListView::String);
+ ui->listView->setContextMenuPolicy(Qt::CustomContextMenu);
+
+ m_accounts = APPLICATION->accounts();
+
+ ui->listView->setModel(m_accounts.get());
+ // Expand the account column
+ ui->listView->header()->setSectionResizeMode(0, QHeaderView::Stretch);
+ ui->listView->header()->setSectionResizeMode(1, QHeaderView::Stretch);
+ ui->listView->header()->setSectionResizeMode(2,
+ QHeaderView::ResizeToContents);
+
+ QItemSelectionModel* selectionModel = ui->listView->selectionModel();
+
+ connect(selectionModel, &QItemSelectionModel::selectionChanged,
+ [this](const QItemSelection& sel, const QItemSelection& dsel) {
+ updateButtonStates();
+ });
+ connect(ui->listView, &VersionListView::customContextMenuRequested, this,
+ &AccountListPage::ShowContextMenu);
+
+ connect(m_accounts.get(), &AccountList::listChanged, this,
+ &AccountListPage::listChanged);
+ connect(m_accounts.get(), &AccountList::listActivityChanged, this,
+ &AccountListPage::listChanged);
+ connect(m_accounts.get(), &AccountList::defaultAccountChanged, this,
+ &AccountListPage::listChanged);
+
+ updateButtonStates();
+
+ // Xbox authentication won't work without a client identifier, so disable
+ // the button if it is missing
+ ui->actionAddMicrosoft->setVisible(!BuildConfig.MSAClientID.isEmpty());
+}
+
+AccountListPage::~AccountListPage()
+{
+ delete ui;
+}
+
+void AccountListPage::ShowContextMenu(const QPoint& pos)
+{
+ auto menu = ui->toolBar->createContextMenu(this, tr("Context menu"));
+ menu->exec(ui->listView->mapToGlobal(pos));
+ delete menu;
+}
+
+void AccountListPage::changeEvent(QEvent* event)
+{
+ if (event->type() == QEvent::LanguageChange) {
+ ui->retranslateUi(this);
+ }
+ QMainWindow::changeEvent(event);
+}
+
+QMenu* AccountListPage::createPopupMenu()
+{
+ QMenu* filteredMenu = QMainWindow::createPopupMenu();
+ filteredMenu->removeAction(ui->toolBar->toggleViewAction());
+ return filteredMenu;
+}
+
+void AccountListPage::listChanged()
+{
+ updateButtonStates();
+}
+
+void AccountListPage::on_actionAddMicrosoft_triggered()
+{
+ if (BuildConfig.BUILD_PLATFORM == "osx64") {
+ CustomMessageBox::selectable(
+ this, tr("Microsoft Accounts not available"),
+ tr("Microsoft accounts are only usable on macOS 10.13 or newer, "
+ "with fully updated MeshMC.\n\n"
+ "Please update both your operating system and MeshMC."),
+ QMessageBox::Warning)
+ ->exec();
+ return;
+ }
+ MinecraftAccountPtr account = MSALoginDialog::newAccount(
+ this, tr("Log in with your Microsoft account to add it."));
+
+ if (account) {
+ m_accounts->addAccount(account);
+ if (m_accounts->count() == 1) {
+ m_accounts->setDefaultAccount(account);
+ }
+ }
+}
+
+void AccountListPage::on_actionRemove_triggered()
+{
+ QModelIndexList selection =
+ ui->listView->selectionModel()->selectedIndexes();
+ if (selection.size() > 0) {
+ QModelIndex selected = selection.first();
+ m_accounts->removeAccount(selected);
+ }
+}
+
+void AccountListPage::on_actionRefresh_triggered()
+{
+ QModelIndexList selection =
+ ui->listView->selectionModel()->selectedIndexes();
+ if (selection.size() > 0) {
+ QModelIndex selected = selection.first();
+ MinecraftAccountPtr account = selected.data(AccountList::PointerRole)
+ .value<MinecraftAccountPtr>();
+ m_accounts->requestRefresh(account->internalId());
+ }
+}
+
+void AccountListPage::on_actionSetDefault_triggered()
+{
+ QModelIndexList selection =
+ ui->listView->selectionModel()->selectedIndexes();
+ if (selection.size() > 0) {
+ QModelIndex selected = selection.first();
+ MinecraftAccountPtr account = selected.data(AccountList::PointerRole)
+ .value<MinecraftAccountPtr>();
+ m_accounts->setDefaultAccount(account);
+ }
+}
+
+void AccountListPage::on_actionNoDefault_triggered()
+{
+ m_accounts->setDefaultAccount(nullptr);
+}
+
+void AccountListPage::updateButtonStates()
+{
+ // If there is no selection, disable buttons that require something
+ // selected.
+ QModelIndexList selection =
+ ui->listView->selectionModel()->selectedIndexes();
+ bool hasSelection = selection.size() > 0;
+ bool accountIsReady = false;
+ if (hasSelection) {
+ QModelIndex selected = selection.first();
+ MinecraftAccountPtr account = selected.data(AccountList::PointerRole)
+ .value<MinecraftAccountPtr>();
+ accountIsReady = !account->isActive();
+ }
+ ui->actionRemove->setEnabled(accountIsReady);
+ ui->actionSetDefault->setEnabled(accountIsReady);
+ ui->actionUploadSkin->setEnabled(accountIsReady);
+ ui->actionDeleteSkin->setEnabled(accountIsReady);
+ ui->actionRefresh->setEnabled(accountIsReady);
+
+ if (m_accounts->defaultAccount().get() == nullptr) {
+ ui->actionNoDefault->setEnabled(false);
+ ui->actionNoDefault->setChecked(true);
+ } else {
+ ui->actionNoDefault->setEnabled(true);
+ ui->actionNoDefault->setChecked(false);
+ }
+}
+
+void AccountListPage::on_actionUploadSkin_triggered()
+{
+ QModelIndexList selection =
+ ui->listView->selectionModel()->selectedIndexes();
+ if (selection.size() > 0) {
+ QModelIndex selected = selection.first();
+ MinecraftAccountPtr account = selected.data(AccountList::PointerRole)
+ .value<MinecraftAccountPtr>();
+ SkinUploadDialog dialog(account, this);
+ dialog.exec();
+ }
+}
+
+void AccountListPage::on_actionDeleteSkin_triggered()
+{
+ QModelIndexList selection =
+ ui->listView->selectionModel()->selectedIndexes();
+ if (selection.size() <= 0)
+ return;
+
+ QModelIndex selected = selection.first();
+ MinecraftAccountPtr account =
+ selected.data(AccountList::PointerRole).value<MinecraftAccountPtr>();
+ ProgressDialog prog(this);
+ auto deleteSkinTask =
+ std::make_shared<SkinDelete>(this, account->accessToken());
+ if (prog.execWithTask((Task*)deleteSkinTask.get()) != QDialog::Accepted) {
+ CustomMessageBox::selectable(this, tr("Skin Delete"),
+ tr("Failed to delete current skin!"),
+ QMessageBox::Warning)
+ ->exec();
+ return;
+ }
+}
diff --git a/meshmc/launcher/ui/pages/global/AccountListPage.h b/meshmc/launcher/ui/pages/global/AccountListPage.h
new file mode 100644
index 0000000000..2e00e0d22a
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/AccountListPage.h
@@ -0,0 +1,106 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QMainWindow>
+#include <memory>
+
+#include "ui/pages/BasePage.h"
+
+#include "minecraft/auth/AccountList.h"
+#include "Application.h"
+
+namespace Ui
+{
+ class AccountListPage;
+}
+
+class AuthenticateTask;
+
+class AccountListPage : public QMainWindow, public BasePage
+{
+ Q_OBJECT
+ public:
+ explicit AccountListPage(QWidget* parent = 0);
+ ~AccountListPage();
+
+ QString displayName() const override
+ {
+ return tr("Accounts");
+ }
+ QIcon icon() const override
+ {
+ auto icon = APPLICATION->getThemedIcon("accounts");
+ if (icon.isNull()) {
+ icon = APPLICATION->getThemedIcon("noaccount");
+ }
+ return icon;
+ }
+ QString id() const override
+ {
+ return "accounts";
+ }
+ QString helpPage() const override
+ {
+ return "Getting-Started#adding-an-account";
+ }
+
+ public slots:
+ void on_actionAddMicrosoft_triggered();
+ void on_actionRemove_triggered();
+ void on_actionRefresh_triggered();
+ void on_actionSetDefault_triggered();
+ void on_actionNoDefault_triggered();
+ void on_actionUploadSkin_triggered();
+ void on_actionDeleteSkin_triggered();
+
+ void listChanged();
+
+ //! Updates the states of the dialog's buttons.
+ void updateButtonStates();
+
+ protected slots:
+ void ShowContextMenu(const QPoint& pos);
+
+ private:
+ void changeEvent(QEvent* event) override;
+ QMenu* createPopupMenu() override;
+ shared_qobject_ptr<AccountList> m_accounts;
+ Ui::AccountListPage* ui;
+};
diff --git a/meshmc/launcher/ui/pages/global/AccountListPage.ui b/meshmc/launcher/ui/pages/global/AccountListPage.ui
new file mode 100644
index 0000000000..96d0dc7518
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/AccountListPage.ui
@@ -0,0 +1,123 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>AccountListPage</class>
+ <widget class="QMainWindow" name="AccountListPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>800</width>
+ <height>600</height>
+ </rect>
+ </property>
+ <widget class="QWidget" name="centralwidget">
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="VersionListView" name="listView">
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <property name="rootIsDecorated">
+ <bool>false</bool>
+ </property>
+ <property name="itemsExpandable">
+ <bool>false</bool>
+ </property>
+ <property name="allColumnsShowFocus">
+ <bool>true</bool>
+ </property>
+ <attribute name="headerStretchLastSection">
+ <bool>false</bool>
+ </attribute>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WideBar" name="toolBar">
+ <attribute name="toolBarArea">
+ <enum>RightToolBarArea</enum>
+ </attribute>
+ <attribute name="toolBarBreak">
+ <bool>false</bool>
+ </attribute>
+ <addaction name="actionAddMicrosoft"/>
+ <addaction name="actionRefresh"/>
+ <addaction name="actionRemove"/>
+ <addaction name="actionSetDefault"/>
+ <addaction name="actionNoDefault"/>
+ <addaction name="separator"/>
+ <addaction name="actionUploadSkin"/>
+ <addaction name="actionDeleteSkin"/>
+ </widget>
+ <action name="actionRemove">
+ <property name="text">
+ <string>Remove</string>
+ </property>
+ </action>
+ <action name="actionSetDefault">
+ <property name="text">
+ <string>Set Default</string>
+ </property>
+ </action>
+ <action name="actionNoDefault">
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="text">
+ <string>No Default</string>
+ </property>
+ </action>
+ <action name="actionUploadSkin">
+ <property name="text">
+ <string>Upload Skin</string>
+ </property>
+ </action>
+ <action name="actionDeleteSkin">
+ <property name="text">
+ <string>Delete Skin</string>
+ </property>
+ <property name="toolTip">
+ <string>Delete the currently active skin and go back to the default one</string>
+ </property>
+ </action>
+ <action name="actionAddMicrosoft">
+ <property name="text">
+ <string>Add Microsoft</string>
+ </property>
+ </action>
+ <action name="actionRefresh">
+ <property name="text">
+ <string>Refresh</string>
+ </property>
+ <property name="toolTip">
+ <string>Refresh the account tokens</string>
+ </property>
+ </action>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>VersionListView</class>
+ <extends>QTreeView</extends>
+ <header>ui/widgets/VersionListView.h</header>
+ </customwidget>
+ <customwidget>
+ <class>WideBar</class>
+ <extends>QToolBar</extends>
+ <header>ui/widgets/WideBar.h</header>
+ </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/global/AppearancePage.cpp b/meshmc/launcher/ui/pages/global/AppearancePage.cpp
new file mode 100644
index 0000000000..dc3838f55b
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/AppearancePage.cpp
@@ -0,0 +1,215 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "AppearancePage.h"
+#include "ui_AppearancePage.h"
+
+#include "Application.h"
+#include "ui/themes/ITheme.h"
+#include "ui/themes/ThemeManager.h"
+#include "ui/themes/CatPack.h"
+
+#include <QGraphicsOpacityEffect>
+
+static const QStringList previewIconNames = {
+ "new", "centralmods", "viewfolder", "launch",
+ "copy", "about", "settings", "accounts"};
+
+AppearancePage::AppearancePage(QWidget* parent)
+ : QWidget(parent), ui(new Ui::AppearancePage)
+{
+ ui->setupUi(this);
+
+ ui->catPreview->setGraphicsEffect(new QGraphicsOpacityEffect(this));
+
+ connect(ui->widgetStyleComboBox,
+ QOverload<int>::of(&QComboBox::currentIndexChanged), this,
+ &AppearancePage::applyWidgetTheme);
+ connect(ui->iconsComboBox,
+ QOverload<int>::of(&QComboBox::currentIndexChanged), this,
+ &AppearancePage::applyIconTheme);
+ connect(ui->catPackComboBox,
+ QOverload<int>::of(&QComboBox::currentIndexChanged), this,
+ &AppearancePage::applyCatTheme);
+
+ loadSettings();
+}
+
+AppearancePage::~AppearancePage()
+{
+ delete ui;
+}
+
+bool AppearancePage::apply()
+{
+ applySettings();
+ return true;
+}
+
+void AppearancePage::applyWidgetTheme(int index)
+{
+ auto settings = APPLICATION->settings();
+ auto originalTheme = settings->get("ApplicationTheme").toString();
+ auto newTheme = ui->widgetStyleComboBox->itemData(index).toString();
+ if (originalTheme != newTheme) {
+ settings->set("ApplicationTheme", newTheme);
+ APPLICATION->themeManager()->applyCurrentlySelectedTheme();
+
+ // Sync icon combo to the auto-resolved icon theme
+ auto resolvedIcon = settings->get("IconTheme").toString();
+ ui->iconsComboBox->blockSignals(true);
+ for (int i = 0; i < ui->iconsComboBox->count(); i++) {
+ if (ui->iconsComboBox->itemData(i).toString() == resolvedIcon) {
+ ui->iconsComboBox->setCurrentIndex(i);
+ break;
+ }
+ }
+ ui->iconsComboBox->blockSignals(false);
+ }
+ updateIconPreview();
+}
+
+void AppearancePage::applyIconTheme(int index)
+{
+ auto settings = APPLICATION->settings();
+ auto originalIconTheme = settings->get("IconTheme").toString();
+ auto newIconTheme = ui->iconsComboBox->itemData(index).toString();
+ if (originalIconTheme != newIconTheme) {
+ settings->set("IconTheme", newIconTheme);
+ APPLICATION->themeManager()->applyCurrentlySelectedTheme();
+ }
+ updateIconPreview();
+}
+
+void AppearancePage::applySettings()
+{
+ // Theme and icon changes are already persisted live via
+ // applyWidgetTheme/applyIconTheme. This is intentionally minimal — settings
+ // are saved on combo change.
+}
+
+void AppearancePage::loadSettings()
+{
+ auto settings = APPLICATION->settings();
+ auto tm = APPLICATION->themeManager();
+
+ // Block signals during population
+ ui->widgetStyleComboBox->blockSignals(true);
+ ui->iconsComboBox->blockSignals(true);
+ ui->catPackComboBox->blockSignals(true);
+
+ // --- Widget themes (flat list) ---
+ ui->widgetStyleComboBox->clear();
+ auto currentThemeId = settings->get("ApplicationTheme").toString();
+ auto themes = tm->allThemes();
+ int themeIdx = 0;
+
+ for (size_t i = 0; i < themes.size(); i++) {
+ auto* theme = themes[i];
+ ui->widgetStyleComboBox->addItem(theme->name(), theme->id());
+ if (!theme->tooltip().isEmpty()) {
+ ui->widgetStyleComboBox->setItemData(
+ static_cast<int>(i), theme->tooltip(), Qt::ToolTipRole);
+ }
+ if (theme->id() == currentThemeId) {
+ themeIdx = static_cast<int>(i);
+ }
+ }
+
+ ui->widgetStyleComboBox->setCurrentIndex(themeIdx);
+
+ // --- Icon themes (flat list) ---
+ ui->iconsComboBox->clear();
+ auto currentIconTheme = settings->get("IconTheme").toString();
+ auto iconThemeList = tm->iconThemes();
+ int iconIdx = 0;
+
+ for (int i = 0; i < iconThemeList.size(); i++) {
+ const auto& entry = iconThemeList[i];
+ ui->iconsComboBox->addItem(entry.name, entry.id);
+ if (entry.id == currentIconTheme) {
+ iconIdx = i;
+ }
+ }
+
+ ui->iconsComboBox->setCurrentIndex(iconIdx);
+
+ // --- Cat Packs ---
+ ui->catPackComboBox->clear();
+ auto currentCat = settings->get("BackgroundCat").toString();
+ auto cats = tm->getValidCatPacks();
+ int catIdx = 0;
+
+ for (int i = 0; i < cats.size(); i++) {
+ auto* cat = cats[i];
+ QIcon catIcon(cat->path());
+ ui->catPackComboBox->addItem(catIcon, cat->name(), cat->id());
+ if (cat->id() == currentCat) {
+ catIdx = i;
+ }
+ }
+
+ ui->catPackComboBox->setCurrentIndex(catIdx);
+
+ // Unblock signals
+ ui->widgetStyleComboBox->blockSignals(false);
+ ui->iconsComboBox->blockSignals(false);
+ ui->catPackComboBox->blockSignals(false);
+
+ // Initial previews
+ updateIconPreview();
+ updateCatPreview();
+}
+
+void AppearancePage::updateIconPreview()
+{
+ QList<QToolButton*> previewButtons = {ui->icon1, ui->icon2, ui->icon3,
+ ui->icon4, ui->icon5, ui->icon6,
+ ui->icon7, ui->icon8};
+
+ for (int i = 0; i < previewButtons.size() && i < previewIconNames.size();
+ i++) {
+ previewButtons[i]->setIcon(
+ APPLICATION->getThemedIcon(previewIconNames[i]));
+ }
+}
+
+void AppearancePage::applyCatTheme(int index)
+{
+ auto settings = APPLICATION->settings();
+ auto originalCat = settings->get("BackgroundCat").toString();
+ auto newCat = ui->catPackComboBox->itemData(index).toString();
+ if (originalCat != newCat) {
+ settings->set("BackgroundCat", newCat);
+ }
+ updateCatPreview();
+}
+
+void AppearancePage::updateCatPreview()
+{
+ QIcon catPackIcon(APPLICATION->themeManager()->getCatPack());
+ ui->catPreview->setIcon(catPackIcon);
+
+ auto effect =
+ dynamic_cast<QGraphicsOpacityEffect*>(ui->catPreview->graphicsEffect());
+ if (effect)
+ effect->setOpacity(1.0);
+}
diff --git a/meshmc/launcher/ui/pages/global/AppearancePage.h b/meshmc/launcher/ui/pages/global/AppearancePage.h
new file mode 100644
index 0000000000..5d83a3a82e
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/AppearancePage.h
@@ -0,0 +1,71 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QWidget>
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+namespace Ui
+{
+ class AppearancePage;
+}
+
+class AppearancePage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit AppearancePage(QWidget* parent = nullptr);
+ ~AppearancePage();
+
+ QString displayName() const override
+ {
+ return tr("Appearance");
+ }
+ QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("appearance");
+ }
+ QString id() const override
+ {
+ return "appearance-settings";
+ }
+ QString helpPage() const override
+ {
+ return "Appearance-settings";
+ }
+ bool apply() override;
+
+ private slots:
+ void applyWidgetTheme(int index);
+ void applyIconTheme(int index);
+ void applyCatTheme(int index);
+
+ private:
+ void applySettings();
+ void loadSettings();
+ void updateIconPreview();
+ void updateCatPreview();
+
+ Ui::AppearancePage* ui;
+};
diff --git a/meshmc/launcher/ui/pages/global/AppearancePage.ui b/meshmc/launcher/ui/pages/global/AppearancePage.ui
new file mode 100644
index 0000000000..edb9bead8a
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/AppearancePage.ui
@@ -0,0 +1,307 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>AppearancePage</class>
+ <widget class="QWidget" name="AppearancePage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>514</width>
+ <height>400</height>
+ </rect>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <layout class="QVBoxLayout" name="mainLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QScrollArea" name="scrollArea">
+ <property name="widgetResizable">
+ <bool>true</bool>
+ </property>
+ <widget class="QWidget" name="scrollAreaContents">
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <layout class="QGridLayout" name="themeGridLayout">
+ <item row="0" column="0">
+ <widget class="QLabel" name="widgetStyleLabel">
+ <property name="text">
+ <string>&amp;Theme:</string>
+ </property>
+ <property name="buddy">
+ <cstring>widgetStyleComboBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QComboBox" name="widgetStyleComboBox">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="focusPolicy">
+ <enum>Qt::StrongFocus</enum>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="iconsLabel">
+ <property name="text">
+ <string>&amp;Icons:</string>
+ </property>
+ <property name="buddy">
+ <cstring>iconsComboBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QComboBox" name="iconsComboBox">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="focusPolicy">
+ <enum>Qt::StrongFocus</enum>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="catPackLabel">
+ <property name="text">
+ <string>&amp;Cat:</string>
+ </property>
+ <property name="buddy">
+ <cstring>catPackComboBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <widget class="QComboBox" name="catPackComboBox">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="focusPolicy">
+ <enum>Qt::StrongFocus</enum>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="previewBox">
+ <property name="title">
+ <string>Preview</string>
+ </property>
+ <layout class="QHBoxLayout" name="previewLayout">
+ <item>
+ <widget class="QToolButton" name="icon1">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="autoRaise">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="icon2">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="autoRaise">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="icon3">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="autoRaise">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="icon4">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="autoRaise">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="icon5">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="autoRaise">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="icon6">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="autoRaise">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="icon7">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="autoRaise">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="icon8">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="autoRaise">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="previewSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>0</width>
+ <height>0</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QPushButton" name="catPreview">
+ <property name="focusPolicy">
+ <enum>Qt::NoFocus</enum>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="flat">
+ <bool>true</bool>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>64</width>
+ <height>128</height>
+ </size>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>0</width>
+ <height>0</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>widgetStyleComboBox</tabstop>
+ <tabstop>iconsComboBox</tabstop>
+ <tabstop>catPackComboBox</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/global/CustomCommandsPage.cpp b/meshmc/launcher/ui/pages/global/CustomCommandsPage.cpp
new file mode 100644
index 0000000000..f17e0f7454
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/CustomCommandsPage.cpp
@@ -0,0 +1,66 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "CustomCommandsPage.h"
+#include <QVBoxLayout>
+#include <QTabWidget>
+#include <QTabBar>
+
+CustomCommandsPage::CustomCommandsPage(QWidget* parent) : QWidget(parent)
+{
+
+ auto verticalLayout = new QVBoxLayout(this);
+ verticalLayout->setObjectName(QStringLiteral("verticalLayout"));
+ verticalLayout->setContentsMargins(0, 0, 0, 0);
+
+ auto tabWidget = new QTabWidget(this);
+ tabWidget->setObjectName(QStringLiteral("tabWidget"));
+ commands = new CustomCommands(this);
+ commands->setContentsMargins(6, 6, 6, 6);
+ tabWidget->addTab(commands, "Foo");
+ tabWidget->tabBar()->hide();
+ verticalLayout->addWidget(tabWidget);
+ loadSettings();
+}
+
+CustomCommandsPage::~CustomCommandsPage() {}
+
+bool CustomCommandsPage::apply()
+{
+ applySettings();
+ return true;
+}
+
+void CustomCommandsPage::applySettings()
+{
+ auto s = APPLICATION->settings();
+ s->set("PreLaunchCommand", commands->prelaunchCommand());
+ s->set("WrapperCommand", commands->wrapperCommand());
+ s->set("PostExitCommand", commands->postexitCommand());
+}
+
+void CustomCommandsPage::loadSettings()
+{
+ auto s = APPLICATION->settings();
+ commands->initialize(false, true, s->get("PreLaunchCommand").toString(),
+ s->get("WrapperCommand").toString(),
+ s->get("PostExitCommand").toString());
+}
diff --git a/meshmc/launcher/ui/pages/global/CustomCommandsPage.h b/meshmc/launcher/ui/pages/global/CustomCommandsPage.h
new file mode 100644
index 0000000000..5419e9ecff
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/CustomCommandsPage.h
@@ -0,0 +1,78 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2018-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.
+ */
+
+#pragma once
+
+#include <memory>
+#include <QDialog>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include "ui/widgets/CustomCommands.h"
+
+class CustomCommandsPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit CustomCommandsPage(QWidget* parent = 0);
+ ~CustomCommandsPage();
+
+ QString displayName() const override
+ {
+ return tr("Custom Commands");
+ }
+ QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("custom-commands");
+ }
+ QString id() const override
+ {
+ return "custom-commands";
+ }
+ QString helpPage() const override
+ {
+ return "Custom-commands";
+ }
+ bool apply() override;
+
+ private:
+ void applySettings();
+ void loadSettings();
+ CustomCommands* commands;
+};
diff --git a/meshmc/launcher/ui/pages/global/ExternalToolsPage.cpp b/meshmc/launcher/ui/pages/global/ExternalToolsPage.cpp
new file mode 100644
index 0000000000..e8303b842b
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/ExternalToolsPage.cpp
@@ -0,0 +1,251 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "ExternalToolsPage.h"
+#include "ui_ExternalToolsPage.h"
+
+#include <QMessageBox>
+#include <QFileDialog>
+#include <QStandardPaths>
+#include <QTabBar>
+
+#include "settings/SettingsObject.h"
+#include "tools/BaseProfiler.h"
+#include <FileSystem.h>
+#include "Application.h"
+#include <tools/MCEditTool.h>
+
+ExternalToolsPage::ExternalToolsPage(QWidget* parent)
+ : QWidget(parent), ui(new Ui::ExternalToolsPage)
+{
+ ui->setupUi(this);
+ ui->tabWidget->tabBar()->hide();
+
+#if QT_VERSION >= QT_VERSION_CHECK(5, 2, 0)
+ ui->jsonEditorTextBox->setClearButtonEnabled(true);
+#endif
+
+ ui->mceditLink->setOpenExternalLinks(true);
+ ui->jvisualvmLink->setOpenExternalLinks(true);
+ ui->jprofilerLink->setOpenExternalLinks(true);
+ loadSettings();
+}
+
+ExternalToolsPage::~ExternalToolsPage()
+{
+ delete ui;
+}
+
+void ExternalToolsPage::loadSettings()
+{
+ auto s = APPLICATION->settings();
+ ui->jprofilerPathEdit->setText(s->get("JProfilerPath").toString());
+ ui->jvisualvmPathEdit->setText(s->get("JVisualVMPath").toString());
+ ui->mceditPathEdit->setText(s->get("MCEditPath").toString());
+
+ // Editors
+ ui->jsonEditorTextBox->setText(s->get("JsonEditor").toString());
+}
+void ExternalToolsPage::applySettings()
+{
+ auto s = APPLICATION->settings();
+
+ s->set("JProfilerPath", ui->jprofilerPathEdit->text());
+ s->set("JVisualVMPath", ui->jvisualvmPathEdit->text());
+ s->set("MCEditPath", ui->mceditPathEdit->text());
+
+ // Editors
+ QString jsonEditor = ui->jsonEditorTextBox->text();
+ if (!jsonEditor.isEmpty() && (!QFileInfo(jsonEditor).exists() ||
+ !QFileInfo(jsonEditor).isExecutable())) {
+ QString found = QStandardPaths::findExecutable(jsonEditor);
+ if (!found.isEmpty()) {
+ jsonEditor = found;
+ }
+ }
+ s->set("JsonEditor", jsonEditor);
+}
+
+void ExternalToolsPage::on_jprofilerPathBtn_clicked()
+{
+ QString raw_dir = ui->jprofilerPathEdit->text();
+ QString error;
+ do {
+ raw_dir = QFileDialog::getExistingDirectory(
+ this, tr("JProfiler Folder"), raw_dir);
+ if (raw_dir.isEmpty()) {
+ break;
+ }
+ QString cooked_dir = FS::NormalizePath(raw_dir);
+ if (!APPLICATION->profilers()["jprofiler"]->check(cooked_dir, &error)) {
+ QMessageBox::critical(
+ this, tr("Error"),
+ tr("Error while checking JProfiler install:\n%1").arg(error));
+ continue;
+ } else {
+ ui->jprofilerPathEdit->setText(cooked_dir);
+ break;
+ }
+ } while (1);
+}
+void ExternalToolsPage::on_jprofilerCheckBtn_clicked()
+{
+ QString error;
+ if (!APPLICATION->profilers()["jprofiler"]->check(
+ ui->jprofilerPathEdit->text(), &error)) {
+ QMessageBox::critical(
+ this, tr("Error"),
+ tr("Error while checking JProfiler install:\n%1").arg(error));
+ } else {
+ QMessageBox::information(this, tr("OK"),
+ tr("JProfiler setup seems to be OK"));
+ }
+}
+
+void ExternalToolsPage::on_jvisualvmPathBtn_clicked()
+{
+ QString raw_dir = ui->jvisualvmPathEdit->text();
+ QString error;
+ do {
+ raw_dir = QFileDialog::getOpenFileName(this, tr("JVisualVM Executable"),
+ raw_dir);
+ if (raw_dir.isEmpty()) {
+ break;
+ }
+ QString cooked_dir = FS::NormalizePath(raw_dir);
+ if (!APPLICATION->profilers()["jvisualvm"]->check(cooked_dir, &error)) {
+ QMessageBox::critical(
+ this, tr("Error"),
+ tr("Error while checking JVisualVM install:\n%1").arg(error));
+ continue;
+ } else {
+ ui->jvisualvmPathEdit->setText(cooked_dir);
+ break;
+ }
+ } while (1);
+}
+void ExternalToolsPage::on_jvisualvmCheckBtn_clicked()
+{
+ QString error;
+ if (!APPLICATION->profilers()["jvisualvm"]->check(
+ ui->jvisualvmPathEdit->text(), &error)) {
+ QMessageBox::critical(
+ this, tr("Error"),
+ tr("Error while checking JVisualVM install:\n%1").arg(error));
+ } else {
+ QMessageBox::information(this, tr("OK"),
+ tr("JVisualVM setup seems to be OK"));
+ }
+}
+
+void ExternalToolsPage::on_mceditPathBtn_clicked()
+{
+ QString raw_dir = ui->mceditPathEdit->text();
+ QString error;
+ do {
+#ifdef Q_OS_MACOS
+ raw_dir = QFileDialog::getOpenFileName(this, tr("MCEdit Application"),
+ raw_dir);
+#else
+ raw_dir = QFileDialog::getExistingDirectory(this, tr("MCEdit Folder"),
+ raw_dir);
+#endif
+ if (raw_dir.isEmpty()) {
+ break;
+ }
+ QString cooked_dir = FS::NormalizePath(raw_dir);
+ if (!APPLICATION->mcedit()->check(cooked_dir, error)) {
+ QMessageBox::critical(
+ this, tr("Error"),
+ tr("Error while checking MCEdit install:\n%1").arg(error));
+ continue;
+ } else {
+ ui->mceditPathEdit->setText(cooked_dir);
+ break;
+ }
+ } while (1);
+}
+void ExternalToolsPage::on_mceditCheckBtn_clicked()
+{
+ QString error;
+ if (!APPLICATION->mcedit()->check(ui->mceditPathEdit->text(), error)) {
+ QMessageBox::critical(
+ this, tr("Error"),
+ tr("Error while checking MCEdit install:\n%1").arg(error));
+ } else {
+ QMessageBox::information(this, tr("OK"),
+ tr("MCEdit setup seems to be OK"));
+ }
+}
+
+void ExternalToolsPage::on_jsonEditorBrowseBtn_clicked()
+{
+ QString raw_file =
+ QFileDialog::getOpenFileName(this, tr("JSON Editor"),
+ ui->jsonEditorTextBox->text().isEmpty()
+#if defined(Q_OS_LINUX)
+ ? QString("/usr/bin")
+#else
+ ? QStandardPaths::standardLocations(
+ QStandardPaths::
+ ApplicationsLocation)
+ .first()
+#endif
+ : ui->jsonEditorTextBox->text());
+
+ if (raw_file.isEmpty()) {
+ return;
+ }
+ QString cooked_file = FS::NormalizePath(raw_file);
+
+ // it has to exist and be an executable
+ if (QFileInfo(cooked_file).exists() &&
+ QFileInfo(cooked_file).isExecutable()) {
+ ui->jsonEditorTextBox->setText(cooked_file);
+ } else {
+ QMessageBox::warning(
+ this, tr("Invalid"),
+ tr("The file chosen does not seem to be an executable"));
+ }
+}
+
+bool ExternalToolsPage::apply()
+{
+ applySettings();
+ return true;
+}
diff --git a/meshmc/launcher/ui/pages/global/ExternalToolsPage.h b/meshmc/launcher/ui/pages/global/ExternalToolsPage.h
new file mode 100644
index 0000000000..ad28d39ab0
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/ExternalToolsPage.h
@@ -0,0 +1,96 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+namespace Ui
+{
+ class ExternalToolsPage;
+}
+
+class ExternalToolsPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit ExternalToolsPage(QWidget* parent = 0);
+ ~ExternalToolsPage();
+
+ QString displayName() const override
+ {
+ return tr("External Tools");
+ }
+ QIcon icon() const override
+ {
+ auto icon = APPLICATION->getThemedIcon("externaltools");
+ if (icon.isNull()) {
+ icon = APPLICATION->getThemedIcon("loadermods");
+ }
+ return icon;
+ }
+ QString id() const override
+ {
+ return "external-tools";
+ }
+ QString helpPage() const override
+ {
+ return "Tools";
+ }
+ virtual bool apply() override;
+
+ private:
+ void loadSettings();
+ void applySettings();
+
+ private:
+ Ui::ExternalToolsPage* ui;
+
+ private slots:
+ void on_jprofilerPathBtn_clicked();
+ void on_jprofilerCheckBtn_clicked();
+ void on_jvisualvmPathBtn_clicked();
+ void on_jvisualvmCheckBtn_clicked();
+ void on_mceditPathBtn_clicked();
+ void on_mceditCheckBtn_clicked();
+ void on_jsonEditorBrowseBtn_clicked();
+};
diff --git a/meshmc/launcher/ui/pages/global/ExternalToolsPage.ui b/meshmc/launcher/ui/pages/global/ExternalToolsPage.ui
new file mode 100644
index 0000000000..e79e938894
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/ExternalToolsPage.ui
@@ -0,0 +1,194 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ExternalToolsPage</class>
+ <widget class="QWidget" name="ExternalToolsPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>673</width>
+ <height>751</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="tab">
+ <attribute name="title">
+ <string notr="true">Tab 1</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <item>
+ <widget class="QGroupBox" name="groupBox_2">
+ <property name="title">
+ <string notr="true">JProfiler</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_10">
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_4">
+ <item>
+ <widget class="QLineEdit" name="jprofilerPathEdit"/>
+ </item>
+ <item>
+ <widget class="QPushButton" name="jprofilerPathBtn">
+ <property name="text">
+ <string notr="true">...</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QPushButton" name="jprofilerCheckBtn">
+ <property name="text">
+ <string>Check</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="jprofilerLink">
+ <property name="text">
+ <string notr="true">&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;a href=&quot;https://www.ej-technologies.com/products/jprofiler/overview.html&quot;&gt;https://www.ej-technologies.com/products/jprofiler/overview.html&lt;/a&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="groupBox_3">
+ <property name="title">
+ <string notr="true">JVisualVM</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_11">
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_5">
+ <item>
+ <widget class="QLineEdit" name="jvisualvmPathEdit"/>
+ </item>
+ <item>
+ <widget class="QPushButton" name="jvisualvmPathBtn">
+ <property name="text">
+ <string notr="true">...</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QPushButton" name="jvisualvmCheckBtn">
+ <property name="text">
+ <string>Check</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="jvisualvmLink">
+ <property name="text">
+ <string notr="true">&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;a href=&quot;https://visualvm.github.io/&quot;&gt;https://visualvm.github.io/&lt;/a&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="groupBox_4">
+ <property name="title">
+ <string notr="true">MCEdit</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_12">
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_6">
+ <item>
+ <widget class="QLineEdit" name="mceditPathEdit"/>
+ </item>
+ <item>
+ <widget class="QPushButton" name="mceditPathBtn">
+ <property name="text">
+ <string notr="true">...</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QPushButton" name="mceditCheckBtn">
+ <property name="text">
+ <string>Check</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="mceditLink">
+ <property name="text">
+ <string notr="true">&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;a href=&quot;https://www.mcedit.net/&quot;&gt;https://www.mcedit.net/&lt;/a&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="editorsBox">
+ <property name="title">
+ <string>External Editors (leave empty for system default)</string>
+ </property>
+ <layout class="QGridLayout" name="foldersBoxLayout_2">
+ <item row="0" column="1">
+ <widget class="QLineEdit" name="jsonEditorTextBox"/>
+ </item>
+ <item row="0" column="0">
+ <widget class="QLabel" name="labelJsonEditor">
+ <property name="text">
+ <string>Text Editor:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="2">
+ <widget class="QToolButton" name="jsonEditorBrowseBtn">
+ <property name="text">
+ <string notr="true">...</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>216</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/global/JavaPage.cpp b/meshmc/launcher/ui/pages/global/JavaPage.cpp
new file mode 100644
index 0000000000..3b3d6b16b9
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/JavaPage.cpp
@@ -0,0 +1,287 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "JavaPage.h"
+#include "JavaCommon.h"
+#include "ui_JavaPage.h"
+
+#include <QFileDialog>
+#include <QMessageBox>
+#include <QDir>
+#include <QTabBar>
+#include <QTreeWidgetItem>
+#include <QDirIterator>
+
+#include "ui/dialogs/VersionSelectDialog.h"
+#ifndef MeshMC_DISABLE_JAVA_DOWNLOADER
+#include "ui/dialogs/JavaDownloadDialog.h"
+#endif
+
+#include "java/JavaUtils.h"
+#include "java/JavaInstallList.h"
+
+#include "settings/SettingsObject.h"
+#include <FileSystem.h>
+#include "Application.h"
+#include <sys.h>
+
+JavaPage::JavaPage(QWidget* parent) : QWidget(parent), ui(new Ui::JavaPage)
+{
+ ui->setupUi(this);
+
+ auto sysMiB = Sys::getSystemRam() / Sys::mebibyte;
+ ui->maxMemSpinBox->setMaximum(sysMiB);
+ loadSettings();
+#ifdef MeshMC_DISABLE_JAVA_DOWNLOADER
+ // Hide the entire Installations tab when Java downloader is disabled
+ int idx = ui->tabWidget->indexOf(ui->tabInstallations);
+ if (idx != -1)
+ ui->tabWidget->removeTab(idx);
+#else
+ refreshInstalledJavas();
+#endif
+}
+
+JavaPage::~JavaPage()
+{
+ delete ui;
+}
+
+bool JavaPage::apply()
+{
+ applySettings();
+ return true;
+}
+
+void JavaPage::applySettings()
+{
+ auto s = APPLICATION->settings();
+
+ // Memory
+ int min = ui->minMemSpinBox->value();
+ int max = ui->maxMemSpinBox->value();
+ if (min < max) {
+ s->set("MinMemAlloc", min);
+ s->set("MaxMemAlloc", max);
+ } else {
+ s->set("MinMemAlloc", max);
+ s->set("MaxMemAlloc", min);
+ }
+ s->set("PermGen", ui->permGenSpinBox->value());
+
+ // Java Settings
+ s->set("JavaPath", ui->javaPathTextBox->text());
+ s->set("JvmArgs", ui->jvmArgsTextBox->text());
+ JavaCommon::checkJVMArgs(s->get("JvmArgs").toString(),
+ this->parentWidget());
+}
+void JavaPage::loadSettings()
+{
+ auto s = APPLICATION->settings();
+ // Memory
+ int min = s->get("MinMemAlloc").toInt();
+ int max = s->get("MaxMemAlloc").toInt();
+ if (min < max) {
+ ui->minMemSpinBox->setValue(min);
+ ui->maxMemSpinBox->setValue(max);
+ } else {
+ ui->minMemSpinBox->setValue(max);
+ ui->maxMemSpinBox->setValue(min);
+ }
+ ui->permGenSpinBox->setValue(s->get("PermGen").toInt());
+
+ // Java Settings
+ ui->javaPathTextBox->setText(s->get("JavaPath").toString());
+ ui->jvmArgsTextBox->setText(s->get("JvmArgs").toString());
+}
+
+void JavaPage::on_javaDetectBtn_clicked()
+{
+ JavaInstallPtr java;
+
+ VersionSelectDialog vselect(APPLICATION->javalist().get(),
+ tr("Select a Java version"), this, true);
+ vselect.setResizeOn(2);
+ vselect.exec();
+
+ if (vselect.result() == QDialog::Accepted && vselect.selectedVersion()) {
+ java =
+ std::dynamic_pointer_cast<JavaInstall>(vselect.selectedVersion());
+ ui->javaPathTextBox->setText(java->path);
+ }
+}
+
+void JavaPage::on_javaBrowseBtn_clicked()
+{
+ QString raw_path =
+ QFileDialog::getOpenFileName(this, tr("Find Java executable"));
+
+ // do not allow current dir - it's dirty. Do not allow dirs that don't exist
+ if (raw_path.isEmpty()) {
+ return;
+ }
+
+ QString cooked_path = FS::NormalizePath(raw_path);
+ QFileInfo javaInfo(cooked_path);
+ ;
+ if (!javaInfo.exists() || !javaInfo.isExecutable()) {
+ return;
+ }
+ ui->javaPathTextBox->setText(cooked_path);
+}
+
+void JavaPage::on_javaTestBtn_clicked()
+{
+ if (checker) {
+ return;
+ }
+ checker.reset(new JavaCommon::TestCheck(
+ this, ui->javaPathTextBox->text(), ui->jvmArgsTextBox->text(),
+ ui->minMemSpinBox->value(), ui->maxMemSpinBox->value(),
+ ui->permGenSpinBox->value()));
+ connect(checker.get(), SIGNAL(finished()), SLOT(checkerFinished()));
+ checker->run();
+}
+
+void JavaPage::checkerFinished()
+{
+ checker.reset();
+}
+
+void JavaPage::on_javaDownloadBtn_clicked()
+{
+#ifndef MeshMC_DISABLE_JAVA_DOWNLOADER
+ JavaDownloadDialog dlg(this);
+ if (dlg.exec() == QDialog::Accepted) {
+ refreshInstalledJavas();
+ }
+#endif
+}
+
+void JavaPage::on_javaRefreshBtn_clicked()
+{
+ refreshInstalledJavas();
+}
+
+void JavaPage::on_javaRemoveBtn_clicked()
+{
+ auto* item = ui->installedJavaTree->currentItem();
+ if (!item)
+ return;
+
+ QString path = item->text(2);
+ if (path.isEmpty())
+ return;
+
+ // Find the java installation root directory (parent of bin/)
+ QFileInfo fi(path);
+ QDir javaDir = fi.dir(); // bin/
+ javaDir.cdUp(); // java root
+
+ auto result = QMessageBox::question(
+ this, tr("Remove Java Installation"),
+ tr("Are you sure you want to remove this Java installation?\n\n%1")
+ .arg(javaDir.absolutePath()),
+ QMessageBox::Yes | QMessageBox::No);
+
+ if (result != QMessageBox::Yes)
+ return;
+
+ javaDir.removeRecursively();
+ refreshInstalledJavas();
+}
+
+void JavaPage::on_javaUseBtn_clicked()
+{
+ auto* item = ui->installedJavaTree->currentItem();
+ if (!item)
+ return;
+
+ QString path = item->text(2);
+ if (!path.isEmpty()) {
+ ui->javaPathTextBox->setText(path);
+ ui->tabWidget->setCurrentIndex(0); // Switch to Settings tab
+ }
+}
+
+void JavaPage::refreshInstalledJavas()
+{
+ ui->installedJavaTree->clear();
+
+ QString javaBaseDir = JavaUtils::managedJavaRoot();
+ QDir baseDir(javaBaseDir);
+ if (!baseDir.exists())
+ return;
+
+ // Scan for java binaries under java/{vendor}/{version}/
+ QDirIterator vendorIt(javaBaseDir, QDir::Dirs | QDir::NoDotAndDotDot);
+ while (vendorIt.hasNext()) {
+ vendorIt.next();
+ QString vendorName = vendorIt.fileName();
+ QString vendorPath = vendorIt.filePath();
+
+ QDirIterator versionIt(vendorPath, QDir::Dirs | QDir::NoDotAndDotDot);
+ while (versionIt.hasNext()) {
+ versionIt.next();
+ QString versionPath = versionIt.filePath();
+
+ // Look for java binary
+#if defined(Q_OS_WIN)
+ QString binaryName = "javaw.exe";
+#else
+ QString binaryName = "java";
+#endif
+ QDirIterator binIt(versionPath, QStringList() << binaryName,
+ QDir::Files, QDirIterator::Subdirectories);
+ while (binIt.hasNext()) {
+ binIt.next();
+ QString javaPath = binIt.filePath();
+ if (javaPath.contains("/bin/")) {
+ auto* item = new QTreeWidgetItem(ui->installedJavaTree);
+ item->setText(0, versionIt.fileName());
+ item->setText(1, vendorName);
+ item->setText(2, javaPath);
+ break; // Only first binary per version dir
+ }
+ }
+ }
+ }
+
+ ui->installedJavaTree->resizeColumnToContents(0);
+ ui->installedJavaTree->resizeColumnToContents(1);
+}
diff --git a/meshmc/launcher/ui/pages/global/JavaPage.h b/meshmc/launcher/ui/pages/global/JavaPage.h
new file mode 100644
index 0000000000..5cd2f99fc4
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/JavaPage.h
@@ -0,0 +1,99 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <memory>
+#include <QDialog>
+#include "ui/pages/BasePage.h"
+#include "JavaCommon.h"
+#include <Application.h>
+#include <QObjectPtr.h>
+
+class SettingsObject;
+
+namespace Ui
+{
+ class JavaPage;
+}
+
+class JavaPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit JavaPage(QWidget* parent = 0);
+ ~JavaPage();
+
+ QString displayName() const override
+ {
+ return tr("Java");
+ }
+ QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("java");
+ }
+ QString id() const override
+ {
+ return "java-settings";
+ }
+ QString helpPage() const override
+ {
+ return "Java-settings";
+ }
+ bool apply() override;
+
+ private:
+ void applySettings();
+ void loadSettings();
+ void refreshInstalledJavas();
+
+ private slots:
+ void on_javaDetectBtn_clicked();
+ void on_javaTestBtn_clicked();
+ void on_javaBrowseBtn_clicked();
+ void on_javaDownloadBtn_clicked();
+ void on_javaRefreshBtn_clicked();
+ void on_javaRemoveBtn_clicked();
+ void on_javaUseBtn_clicked();
+ void checkerFinished();
+
+ private:
+ Ui::JavaPage* ui;
+ unique_qobject_ptr<JavaCommon::TestCheck> checker;
+};
diff --git a/meshmc/launcher/ui/pages/global/JavaPage.ui b/meshmc/launcher/ui/pages/global/JavaPage.ui
new file mode 100644
index 0000000000..8222a29679
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/JavaPage.ui
@@ -0,0 +1,346 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>JavaPage</class>
+ <widget class="QWidget" name="JavaPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>545</width>
+ <height>580</height>
+ </rect>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="tab">
+ <attribute name="title">
+ <string>Settings</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <item>
+ <widget class="QGroupBox" name="memoryGroupBox">
+ <property name="title">
+ <string>Memory</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="1" column="1">
+ <widget class="QSpinBox" name="maxMemSpinBox">
+ <property name="toolTip">
+ <string>The maximum amount of memory Minecraft is allowed to use.</string>
+ </property>
+ <property name="suffix">
+ <string notr="true"> MiB</string>
+ </property>
+ <property name="minimum">
+ <number>128</number>
+ </property>
+ <property name="maximum">
+ <number>65536</number>
+ </property>
+ <property name="singleStep">
+ <number>128</number>
+ </property>
+ <property name="value">
+ <number>1024</number>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0">
+ <widget class="QLabel" name="labelMinMem">
+ <property name="text">
+ <string>Minimum memory allocation:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="labelMaxMem">
+ <property name="text">
+ <string>Maximum memory allocation:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QSpinBox" name="minMemSpinBox">
+ <property name="toolTip">
+ <string>The amount of memory Minecraft is started with.</string>
+ </property>
+ <property name="suffix">
+ <string notr="true"> MiB</string>
+ </property>
+ <property name="minimum">
+ <number>128</number>
+ </property>
+ <property name="maximum">
+ <number>65536</number>
+ </property>
+ <property name="singleStep">
+ <number>128</number>
+ </property>
+ <property name="value">
+ <number>256</number>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="labelPermGen">
+ <property name="text">
+ <string notr="true">PermGen:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <widget class="QSpinBox" name="permGenSpinBox">
+ <property name="toolTip">
+ <string>The amount of memory available to store loaded Java classes.</string>
+ </property>
+ <property name="suffix">
+ <string notr="true"> MiB</string>
+ </property>
+ <property name="minimum">
+ <number>64</number>
+ </property>
+ <property name="maximum">
+ <number>999999999</number>
+ </property>
+ <property name="singleStep">
+ <number>8</number>
+ </property>
+ <property name="value">
+ <number>64</number>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="javaSettingsGroupBox">
+ <property name="title">
+ <string>Java Runtime</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_3">
+ <item row="0" column="0">
+ <widget class="QLabel" name="labelJavaPath">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>Java path:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1" colspan="2">
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <item>
+ <widget class="QLineEdit" name="javaPathTextBox"/>
+ </item>
+ <item>
+ <widget class="QPushButton" name="javaBrowseBtn">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="maximumSize">
+ <size>
+ <width>28</width>
+ <height>16777215</height>
+ </size>
+ </property>
+ <property name="text">
+ <string notr="true">...</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="2" column="1" colspan="2">
+ <widget class="QLineEdit" name="jvmArgsTextBox"/>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="labelJVMArgs">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>JVM arguments:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="1">
+ <widget class="QPushButton" name="javaDetectBtn">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>Auto-detect...</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="2">
+ <widget class="QPushButton" name="javaTestBtn">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>Test</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="tabInstallations">
+ <attribute name="title">
+ <string>Installations</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="installationsLayout">
+ <item>
+ <widget class="QGroupBox" name="installedGroupBox">
+ <property name="title">
+ <string>Installed Java Runtimes</string>
+ </property>
+ <layout class="QVBoxLayout" name="installedLayout">
+ <item>
+ <widget class="QTreeWidget" name="installedJavaTree">
+ <property name="rootIsDecorated">
+ <bool>false</bool>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <column>
+ <property name="text">
+ <string>Version</string>
+ </property>
+ </column>
+ <column>
+ <property name="text">
+ <string>Vendor</string>
+ </property>
+ </column>
+ <column>
+ <property name="text">
+ <string>Path</string>
+ </property>
+ </column>
+ </widget>
+ </item>
+ <item>
+ <layout class="QHBoxLayout" name="installBtnLayout">
+ <item>
+ <widget class="QPushButton" name="javaDownloadBtn">
+ <property name="text">
+ <string>Download Java...</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="javaRefreshBtn">
+ <property name="text">
+ <string>Refresh</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="javaRemoveBtn">
+ <property name="text">
+ <string>Remove</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="installBtnSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QPushButton" name="javaUseBtn">
+ <property name="text">
+ <string>Use Selected</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>minMemSpinBox</tabstop>
+ <tabstop>maxMemSpinBox</tabstop>
+ <tabstop>permGenSpinBox</tabstop>
+ <tabstop>javaBrowseBtn</tabstop>
+ <tabstop>javaPathTextBox</tabstop>
+ <tabstop>jvmArgsTextBox</tabstop>
+ <tabstop>javaDetectBtn</tabstop>
+ <tabstop>javaTestBtn</tabstop>
+ <tabstop>tabWidget</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/global/LanguagePage.cpp b/meshmc/launcher/ui/pages/global/LanguagePage.cpp
new file mode 100644
index 0000000000..f52c9429ac
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/LanguagePage.cpp
@@ -0,0 +1,68 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "LanguagePage.h"
+
+#include "ui/widgets/LanguageSelectionWidget.h"
+#include <QVBoxLayout>
+
+LanguagePage::LanguagePage(QWidget* parent) : QWidget(parent)
+{
+ setObjectName(QStringLiteral("languagePage"));
+ auto layout = new QVBoxLayout(this);
+ mainWidget = new LanguageSelectionWidget(this);
+ layout->setContentsMargins(0, 0, 0, 0);
+ layout->addWidget(mainWidget);
+ retranslate();
+}
+
+LanguagePage::~LanguagePage() {}
+
+bool LanguagePage::apply()
+{
+ applySettings();
+ return true;
+}
+
+void LanguagePage::applySettings()
+{
+ auto settings = APPLICATION->settings();
+ QString key = mainWidget->getSelectedLanguageKey();
+ settings->set("Language", key);
+}
+
+void LanguagePage::loadSettings()
+{
+ // NIL
+}
+
+void LanguagePage::retranslate()
+{
+ mainWidget->retranslate();
+}
+
+void LanguagePage::changeEvent(QEvent* event)
+{
+ if (event->type() == QEvent::LanguageChange) {
+ retranslate();
+ }
+ QWidget::changeEvent(event);
+}
diff --git a/meshmc/launcher/ui/pages/global/LanguagePage.h b/meshmc/launcher/ui/pages/global/LanguagePage.h
new file mode 100644
index 0000000000..50d5198490
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/LanguagePage.h
@@ -0,0 +1,83 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <memory>
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include <QWidget>
+
+class LanguageSelectionWidget;
+
+class LanguagePage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit LanguagePage(QWidget* parent = 0);
+ virtual ~LanguagePage();
+
+ QString displayName() const override
+ {
+ return tr("Language");
+ }
+ QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("language");
+ }
+ QString id() const override
+ {
+ return "language-settings";
+ }
+ QString helpPage() const override
+ {
+ return "Language-settings";
+ }
+ bool apply() override;
+
+ void changeEvent(QEvent*) override;
+
+ private:
+ void applySettings();
+ void loadSettings();
+ void retranslate();
+
+ private:
+ LanguageSelectionWidget* mainWidget;
+};
diff --git a/meshmc/launcher/ui/pages/global/MeshMCPage.cpp b/meshmc/launcher/ui/pages/global/MeshMCPage.cpp
new file mode 100644
index 0000000000..fc5d406974
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/MeshMCPage.cpp
@@ -0,0 +1,318 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "MeshMCPage.h"
+#include "ui_MeshMCPage.h"
+
+#include <QFileDialog>
+#include <QMessageBox>
+#include <QDir>
+#include <QTextCharFormat>
+
+#include "updater/UpdateChecker.h"
+
+#include "settings/SettingsObject.h"
+#include <FileSystem.h>
+#include "Application.h"
+#include "BuildConfig.h"
+
+#include <QApplication>
+#include <QProcess>
+
+// FIXME: possibly move elsewhere
+enum InstSortMode {
+ // Sort alphabetically by name.
+ Sort_Name,
+ // Sort by which instance was launched most recently.
+ Sort_LastLaunch
+};
+
+MeshMCPage::MeshMCPage(QWidget* parent)
+ : QWidget(parent), ui(new Ui::MeshMCPage)
+{
+ ui->setupUi(this);
+ auto origForeground =
+ ui->fontPreview->palette().color(ui->fontPreview->foregroundRole());
+ auto origBackground =
+ ui->fontPreview->palette().color(ui->fontPreview->backgroundRole());
+ m_colors.reset(new LogColorCache(origForeground, origBackground));
+
+ ui->sortingModeGroup->setId(ui->sortByNameBtn, Sort_Name);
+ ui->sortingModeGroup->setId(ui->sortLastLaunchedBtn, Sort_LastLaunch);
+
+ defaultFormat = new QTextCharFormat(ui->fontPreview->currentCharFormat());
+
+ m_languageModel = APPLICATION->translations();
+ loadSettings();
+
+ if (BuildConfig.UPDATER_ENABLED && UpdateChecker::isUpdaterSupported()) {
+ // New updater: hide the legacy channel selector (no channel selection
+ // in the new system).
+ ui->updateChannelComboBox->setVisible(false);
+ ui->updateChannelLabel->setVisible(false);
+ ui->updateChannelDescLabel->setVisible(false);
+ } else {
+ ui->updateSettingsBox->setHidden(true);
+ }
+ // Analytics
+ if (BuildConfig.ANALYTICS_ID.isEmpty()) {
+ ui->tabWidget->removeTab(ui->tabWidget->indexOf(ui->analyticsTab));
+ }
+ connect(ui->fontSizeBox, SIGNAL(valueChanged(int)),
+ SLOT(refreshFontPreview()));
+ connect(ui->consoleFont, SIGNAL(currentFontChanged(QFont)),
+ SLOT(refreshFontPreview()));
+
+ ui->migrateDataFolderMacBtn->setVisible(false);
+}
+
+MeshMCPage::~MeshMCPage()
+{
+ delete ui;
+ delete defaultFormat;
+}
+
+bool MeshMCPage::apply()
+{
+ applySettings();
+ return true;
+}
+
+void MeshMCPage::on_instDirBrowseBtn_clicked()
+{
+ QString raw_dir = QFileDialog::getExistingDirectory(
+ this, tr("Instance Folder"), ui->instDirTextBox->text());
+
+ // do not allow current dir - it's dirty. Do not allow dirs that don't exist
+ if (!raw_dir.isEmpty() && QDir(raw_dir).exists()) {
+ QString cooked_dir = FS::NormalizePath(raw_dir);
+ if (FS::checkProblemticPathJava(QDir(cooked_dir))) {
+ QMessageBox warning;
+ warning.setText(
+ tr("You're trying to specify an instance folder which\'s path "
+ "contains at least one \'!\'. "
+ "Java is known to cause problems if that is the case, your "
+ "instances (probably) won't start!"));
+ warning.setInformativeText(
+ tr("Do you really want to use this path? "
+ "Selecting \"No\" will close this and not alter your "
+ "instance path."));
+ warning.setStandardButtons(QMessageBox::Yes | QMessageBox::No);
+ int result = warning.exec();
+ if (result == QMessageBox::Yes) {
+ ui->instDirTextBox->setText(cooked_dir);
+ }
+ } else {
+ ui->instDirTextBox->setText(cooked_dir);
+ }
+ }
+}
+
+void MeshMCPage::on_iconsDirBrowseBtn_clicked()
+{
+ QString raw_dir = QFileDialog::getExistingDirectory(
+ this, tr("Icons Folder"), ui->iconsDirTextBox->text());
+
+ // do not allow current dir - it's dirty. Do not allow dirs that don't exist
+ if (!raw_dir.isEmpty() && QDir(raw_dir).exists()) {
+ QString cooked_dir = FS::NormalizePath(raw_dir);
+ ui->iconsDirTextBox->setText(cooked_dir);
+ }
+}
+void MeshMCPage::on_modsDirBrowseBtn_clicked()
+{
+ QString raw_dir = QFileDialog::getExistingDirectory(
+ this, tr("Mods Folder"), ui->modsDirTextBox->text());
+
+ // do not allow current dir - it's dirty. Do not allow dirs that don't exist
+ if (!raw_dir.isEmpty() && QDir(raw_dir).exists()) {
+ QString cooked_dir = FS::NormalizePath(raw_dir);
+ ui->modsDirTextBox->setText(cooked_dir);
+ }
+}
+void MeshMCPage::on_migrateDataFolderMacBtn_clicked()
+{
+ QMessageBox::information(
+ this, tr("Automatic macOS Migration"),
+ tr("%1 now stores macOS data under your Library/Application Support "
+ "folder automatically.")
+ .arg(BuildConfig.MESHMC_DISPLAYNAME));
+}
+
+void MeshMCPage::refreshUpdateChannelList()
+{
+ // No-op: the new updater does not use named channels.
+}
+
+void MeshMCPage::updateChannelSelectionChanged(int)
+{
+ // No-op.
+}
+
+void MeshMCPage::refreshUpdateChannelDesc()
+{
+ // No-op.
+}
+
+void MeshMCPage::applySettings()
+{
+ auto s = APPLICATION->settings();
+
+ if (ui->resetNotificationsBtn->isChecked()) {
+ s->set("ShownNotifications", QString());
+ }
+
+ // Updates
+ s->set("AutoUpdate", ui->autoUpdateCheckBox->isChecked());
+ // (UpdateChannel setting removed - the new updater always checks the stable
+ // feed)
+
+ // Console settings
+ s->set("ShowConsole", ui->showConsoleCheck->isChecked());
+ s->set("AutoCloseConsole", ui->autoCloseConsoleCheck->isChecked());
+ s->set("ShowConsoleOnError", ui->showConsoleErrorCheck->isChecked());
+ QString consoleFontFamily = ui->consoleFont->currentFont().family();
+ s->set("ConsoleFont", consoleFontFamily);
+ s->set("ConsoleFontSize", ui->fontSizeBox->value());
+ s->set("ConsoleMaxLines", ui->lineLimitSpinBox->value());
+ s->set("ConsoleOverflowStop",
+ ui->checkStopLogging->checkState() != Qt::Unchecked);
+
+ // Folders
+ // TODO: Offer to move instances to new instance folder.
+ s->set("InstanceDir", ui->instDirTextBox->text());
+ s->set("CentralModsDir", ui->modsDirTextBox->text());
+ s->set("IconsDir", ui->iconsDirTextBox->text());
+
+ auto sortMode = (InstSortMode)ui->sortingModeGroup->checkedId();
+ switch (sortMode) {
+ case Sort_LastLaunch:
+ s->set("InstSortMode", "LastLaunch");
+ break;
+ case Sort_Name:
+ default:
+ s->set("InstSortMode", "Name");
+ break;
+ }
+
+ // Analytics
+ if (!BuildConfig.ANALYTICS_ID.isEmpty()) {
+ s->set("Analytics", ui->analyticsCheck->isChecked());
+ }
+}
+void MeshMCPage::loadSettings()
+{
+ auto s = APPLICATION->settings();
+ // Updates
+ ui->autoUpdateCheckBox->setChecked(s->get("AutoUpdate").toBool());
+ // (no channel to read in the new updater system)
+
+ // Console settings
+ ui->showConsoleCheck->setChecked(s->get("ShowConsole").toBool());
+ ui->autoCloseConsoleCheck->setChecked(s->get("AutoCloseConsole").toBool());
+ ui->showConsoleErrorCheck->setChecked(
+ s->get("ShowConsoleOnError").toBool());
+ QString fontFamily = APPLICATION->settings()->get("ConsoleFont").toString();
+ QFont consoleFont(fontFamily);
+ ui->consoleFont->setCurrentFont(consoleFont);
+
+ bool conversionOk = true;
+ int fontSize =
+ APPLICATION->settings()->get("ConsoleFontSize").toInt(&conversionOk);
+ if (!conversionOk) {
+ fontSize = 11;
+ }
+ ui->fontSizeBox->setValue(fontSize);
+ refreshFontPreview();
+ ui->lineLimitSpinBox->setValue(s->get("ConsoleMaxLines").toInt());
+ ui->checkStopLogging->setChecked(s->get("ConsoleOverflowStop").toBool());
+
+ // Folders
+ ui->instDirTextBox->setText(s->get("InstanceDir").toString());
+ ui->modsDirTextBox->setText(s->get("CentralModsDir").toString());
+ ui->iconsDirTextBox->setText(s->get("IconsDir").toString());
+
+ QString sortMode = s->get("InstSortMode").toString();
+
+ if (sortMode == "LastLaunch") {
+ ui->sortLastLaunchedBtn->setChecked(true);
+ } else {
+ ui->sortByNameBtn->setChecked(true);
+ }
+
+ // Analytics
+ if (!BuildConfig.ANALYTICS_ID.isEmpty()) {
+ ui->analyticsCheck->setChecked(s->get("Analytics").toBool());
+ }
+}
+
+void MeshMCPage::refreshFontPreview()
+{
+ int fontSize = ui->fontSizeBox->value();
+ QString fontFamily = ui->consoleFont->currentFont().family();
+ ui->fontPreview->clear();
+ defaultFormat->setFont(QFont(fontFamily, fontSize));
+ {
+ QTextCharFormat format(*defaultFormat);
+ format.setForeground(m_colors->getFront(MessageLevel::Error));
+ // append a paragraph/line
+ auto workCursor = ui->fontPreview->textCursor();
+ workCursor.movePosition(QTextCursor::End);
+ workCursor.insertText(tr("[Something/ERROR] A spooky error!"), format);
+ workCursor.insertBlock();
+ }
+ {
+ QTextCharFormat format(*defaultFormat);
+ format.setForeground(m_colors->getFront(MessageLevel::Message));
+ // append a paragraph/line
+ auto workCursor = ui->fontPreview->textCursor();
+ workCursor.movePosition(QTextCursor::End);
+ workCursor.insertText(tr("[Test/INFO] A harmless message..."), format);
+ workCursor.insertBlock();
+ }
+ {
+ QTextCharFormat format(*defaultFormat);
+ format.setForeground(m_colors->getFront(MessageLevel::Warning));
+ // append a paragraph/line
+ auto workCursor = ui->fontPreview->textCursor();
+ workCursor.movePosition(QTextCursor::End);
+ workCursor.insertText(tr("[Something/WARN] A not so spooky warning."),
+ format);
+ workCursor.insertBlock();
+ }
+}
diff --git a/meshmc/launcher/ui/pages/global/MeshMCPage.h b/meshmc/launcher/ui/pages/global/MeshMCPage.h
new file mode 100644
index 0000000000..f2cdb58b8e
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/MeshMCPage.h
@@ -0,0 +1,120 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <memory>
+#include <QDialog>
+
+#include "java/JavaChecker.h"
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include "ui/ColorCache.h"
+#include <translations/TranslationsModel.h>
+
+class QTextCharFormat;
+class SettingsObject;
+
+namespace Ui
+{
+ class MeshMCPage;
+}
+
+class MeshMCPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit MeshMCPage(QWidget* parent = 0);
+ ~MeshMCPage();
+
+ QString displayName() const override
+ {
+ return "MeshMC";
+ }
+ QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("launcher");
+ }
+ QString id() const override
+ {
+ return "launcher-settings";
+ }
+ QString helpPage() const override
+ {
+ return "MeshMC-settings";
+ }
+ bool apply() override;
+
+ private:
+ void applySettings();
+ void loadSettings();
+
+ private slots:
+ void on_instDirBrowseBtn_clicked();
+ void on_modsDirBrowseBtn_clicked();
+ void on_iconsDirBrowseBtn_clicked();
+ void on_migrateDataFolderMacBtn_clicked();
+
+ /*!
+ * Updates the list of update channels in the combo box.
+ */
+ void refreshUpdateChannelList();
+
+ /*!
+ * Updates the channel description label.
+ */
+ void refreshUpdateChannelDesc();
+
+ /*!
+ * Updates the font preview
+ */
+ void refreshFontPreview();
+
+ void updateChannelSelectionChanged(int index);
+
+ private:
+ Ui::MeshMCPage* ui;
+
+ // default format for the font preview...
+ QTextCharFormat* defaultFormat;
+
+ std::unique_ptr<LogColorCache> m_colors;
+
+ std::shared_ptr<TranslationsModel> m_languageModel;
+};
diff --git a/meshmc/launcher/ui/pages/global/MeshMCPage.ui b/meshmc/launcher/ui/pages/global/MeshMCPage.ui
new file mode 100644
index 0000000000..9d822b2952
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/MeshMCPage.ui
@@ -0,0 +1,482 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MeshMCPage</class>
+ <widget class="QWidget" name="MeshMCPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>514</width>
+ <height>629</height>
+ </rect>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <layout class="QVBoxLayout" name="mainLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="tabShape">
+ <enum>QTabWidget::Rounded</enum>
+ </property>
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="featuresTab">
+ <attribute name="title">
+ <string>Features</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_9">
+ <item>
+ <widget class="QGroupBox" name="updateSettingsBox">
+ <property name="title">
+ <string>Update Settings</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_7">
+ <item>
+ <widget class="QCheckBox" name="autoUpdateCheckBox">
+ <property name="text">
+ <string>Check for updates on start?</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="updateChannelLabel">
+ <property name="text">
+ <string>Up&amp;date Channel:</string>
+ </property>
+ <property name="buddy">
+ <cstring>updateChannelComboBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QComboBox" name="updateChannelComboBox">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="updateChannelDescLabel">
+ <property name="text">
+ <string>No channel selected.</string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="foldersBox">
+ <property name="title">
+ <string>Folders</string>
+ </property>
+ <layout class="QGridLayout" name="foldersBoxLayout">
+ <item row="0" column="0">
+ <widget class="QLabel" name="labelInstDir">
+ <property name="text">
+ <string>I&amp;nstances:</string>
+ </property>
+ <property name="buddy">
+ <cstring>instDirTextBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QLineEdit" name="instDirTextBox"/>
+ </item>
+ <item row="0" column="2">
+ <widget class="QToolButton" name="instDirBrowseBtn">
+ <property name="text">
+ <string notr="true">...</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="labelModsDir">
+ <property name="text">
+ <string>&amp;Mods:</string>
+ </property>
+ <property name="buddy">
+ <cstring>modsDirTextBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLineEdit" name="modsDirTextBox"/>
+ </item>
+ <item row="1" column="2">
+ <widget class="QToolButton" name="modsDirBrowseBtn">
+ <property name="text">
+ <string notr="true">...</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <widget class="QLineEdit" name="iconsDirTextBox"/>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="labelIconsDir">
+ <property name="text">
+ <string>&amp;Icons:</string>
+ </property>
+ <property name="buddy">
+ <cstring>iconsDirTextBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="2">
+ <widget class="QToolButton" name="iconsDirBrowseBtn">
+ <property name="text">
+ <string notr="true">...</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="migrateDataFolderMacBtn">
+ <property name="text">
+ <string>Move the data to new location (will restart MeshMC)</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer_2">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>0</width>
+ <height>0</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="generalTab">
+ <attribute name="title">
+ <string>User Interface</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_6">
+ <item>
+ <widget class="QGroupBox" name="groupBox_3">
+ <property name="title">
+ <string>MeshMC notifications</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_5">
+ <item>
+ <widget class="QPushButton" name="resetNotificationsBtn">
+ <property name="text">
+ <string>Reset hidden notifications</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="sortingModeBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Instance view sorting mode</string>
+ </property>
+ <layout class="QHBoxLayout" name="sortingModeBoxLayout">
+ <item>
+ <widget class="QRadioButton" name="sortLastLaunchedBtn">
+ <property name="text">
+ <string>By &amp;last launched</string>
+ </property>
+ <attribute name="buttonGroup">
+ <string notr="true">sortingModeGroup</string>
+ </attribute>
+ </widget>
+ </item>
+ <item>
+ <widget class="QRadioButton" name="sortByNameBtn">
+ <property name="text">
+ <string>By &amp;name</string>
+ </property>
+ <attribute name="buttonGroup">
+ <string notr="true">sortingModeGroup</string>
+ </attribute>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="generalTabSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>0</width>
+ <height>0</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="consoleTab">
+ <attribute name="title">
+ <string>Console</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <item>
+ <widget class="QGroupBox" name="consoleSettingsBox">
+ <property name="title">
+ <string>Console Settings</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_3">
+ <item>
+ <widget class="QCheckBox" name="showConsoleCheck">
+ <property name="text">
+ <string>Show console while the game is running?</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="autoCloseConsoleCheck">
+ <property name="text">
+ <string>Automatically close console when the game quits?</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="showConsoleErrorCheck">
+ <property name="text">
+ <string>Show console when the game crashes?</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="groupBox_4">
+ <property name="title">
+ <string>History limit</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_3">
+ <item row="1" column="0">
+ <widget class="QCheckBox" name="checkStopLogging">
+ <property name="text">
+ <string>Stop logging when log overflows</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0">
+ <widget class="QSpinBox" name="lineLimitSpinBox">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="suffix">
+ <string> lines</string>
+ </property>
+ <property name="minimum">
+ <number>10000</number>
+ </property>
+ <property name="maximum">
+ <number>1000000</number>
+ </property>
+ <property name="singleStep">
+ <number>10000</number>
+ </property>
+ <property name="value">
+ <number>100000</number>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="themeBox_2">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="title">
+ <string>Console font</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="1" column="0" colspan="2">
+ <widget class="QTextEdit" name="fontPreview">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="MinimumExpanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="horizontalScrollBarPolicy">
+ <enum>Qt::ScrollBarAlwaysOff</enum>
+ </property>
+ <property name="undoRedoEnabled">
+ <bool>false</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0">
+ <widget class="QFontComboBox" name="consoleFont">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QSpinBox" name="fontSizeBox">
+ <property name="minimum">
+ <number>5</number>
+ </property>
+ <property name="maximum">
+ <number>16</number>
+ </property>
+ <property name="value">
+ <number>11</number>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="analyticsTab">
+ <attribute name="title">
+ <string>Analytics</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_8">
+ <item>
+ <widget class="QGroupBox" name="consoleSettingsBox_2">
+ <property name="title">
+ <string>Analytics Settings</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_4">
+ <item>
+ <widget class="QCheckBox" name="analyticsCheck">
+ <property name="text">
+ <string>Send anonymous usage statistics?</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="Line" name="line">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="label_5">
+ <property name="text">
+ <string>&lt;html&gt;&lt;head/&gt;
+&lt;body&gt;
+&lt;p&gt;MeshMC sends anonymous usage statistics on every start of the application.&lt;/p&gt;&lt;p&gt;The following data is collected:&lt;/p&gt;
+&lt;ul&gt;
+&lt;li&gt;MeshMC version.&lt;/li&gt;
+&lt;li&gt;Operating system name, version and architecture.&lt;/li&gt;
+&lt;li&gt;CPU architecture (kernel architecture on linux).&lt;/li&gt;
+&lt;li&gt;Size of system memory.&lt;/li&gt;
+&lt;li&gt;Java version, architecture and memory settings.&lt;/li&gt;
+&lt;/ul&gt;
+&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>tabWidget</tabstop>
+ <tabstop>autoUpdateCheckBox</tabstop>
+ <tabstop>updateChannelComboBox</tabstop>
+ <tabstop>instDirTextBox</tabstop>
+ <tabstop>instDirBrowseBtn</tabstop>
+ <tabstop>modsDirTextBox</tabstop>
+ <tabstop>modsDirBrowseBtn</tabstop>
+ <tabstop>iconsDirTextBox</tabstop>
+ <tabstop>iconsDirBrowseBtn</tabstop>
+ <tabstop>resetNotificationsBtn</tabstop>
+ <tabstop>sortLastLaunchedBtn</tabstop>
+ <tabstop>sortByNameBtn</tabstop>
+ <tabstop>showConsoleCheck</tabstop>
+ <tabstop>autoCloseConsoleCheck</tabstop>
+ <tabstop>showConsoleErrorCheck</tabstop>
+ <tabstop>lineLimitSpinBox</tabstop>
+ <tabstop>checkStopLogging</tabstop>
+ <tabstop>consoleFont</tabstop>
+ <tabstop>fontSizeBox</tabstop>
+ <tabstop>fontPreview</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+ <buttongroups>
+ <buttongroup name="sortingModeGroup"/>
+ </buttongroups>
+</ui>
diff --git a/meshmc/launcher/ui/pages/global/MinecraftPage.cpp b/meshmc/launcher/ui/pages/global/MinecraftPage.cpp
new file mode 100644
index 0000000000..1ec9fcba1a
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/MinecraftPage.cpp
@@ -0,0 +1,115 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "MinecraftPage.h"
+#include "ui_MinecraftPage.h"
+
+#include <QMessageBox>
+#include <QDir>
+#include <QTabBar>
+
+#include "settings/SettingsObject.h"
+#include "Application.h"
+
+MinecraftPage::MinecraftPage(QWidget* parent)
+ : QWidget(parent), ui(new Ui::MinecraftPage)
+{
+ ui->setupUi(this);
+ ui->tabWidget->tabBar()->hide();
+ loadSettings();
+ updateCheckboxStuff();
+}
+
+MinecraftPage::~MinecraftPage()
+{
+ delete ui;
+}
+
+bool MinecraftPage::apply()
+{
+ applySettings();
+ return true;
+}
+
+void MinecraftPage::updateCheckboxStuff()
+{
+ ui->windowWidthSpinBox->setEnabled(!ui->maximizedCheckBox->isChecked());
+ ui->windowHeightSpinBox->setEnabled(!ui->maximizedCheckBox->isChecked());
+}
+
+void MinecraftPage::on_maximizedCheckBox_clicked(bool checked)
+{
+ Q_UNUSED(checked);
+ updateCheckboxStuff();
+}
+
+void MinecraftPage::applySettings()
+{
+ auto s = APPLICATION->settings();
+
+ // Window Size
+ s->set("LaunchMaximized", ui->maximizedCheckBox->isChecked());
+ s->set("MinecraftWinWidth", ui->windowWidthSpinBox->value());
+ s->set("MinecraftWinHeight", ui->windowHeightSpinBox->value());
+
+ // Native library workarounds
+ s->set("UseNativeOpenAL", ui->useNativeOpenALCheck->isChecked());
+ s->set("UseNativeGLFW", ui->useNativeGLFWCheck->isChecked());
+
+ // Game time
+ s->set("ShowGameTime", ui->showGameTime->isChecked());
+ s->set("ShowGlobalGameTime", ui->showGlobalGameTime->isChecked());
+ s->set("RecordGameTime", ui->recordGameTime->isChecked());
+}
+
+void MinecraftPage::loadSettings()
+{
+ auto s = APPLICATION->settings();
+
+ // Window Size
+ ui->maximizedCheckBox->setChecked(s->get("LaunchMaximized").toBool());
+ ui->windowWidthSpinBox->setValue(s->get("MinecraftWinWidth").toInt());
+ ui->windowHeightSpinBox->setValue(s->get("MinecraftWinHeight").toInt());
+
+ ui->useNativeOpenALCheck->setChecked(s->get("UseNativeOpenAL").toBool());
+ ui->useNativeGLFWCheck->setChecked(s->get("UseNativeGLFW").toBool());
+
+ ui->showGameTime->setChecked(s->get("ShowGameTime").toBool());
+ ui->showGlobalGameTime->setChecked(s->get("ShowGlobalGameTime").toBool());
+ ui->recordGameTime->setChecked(s->get("RecordGameTime").toBool());
+}
diff --git a/meshmc/launcher/ui/pages/global/MinecraftPage.h b/meshmc/launcher/ui/pages/global/MinecraftPage.h
new file mode 100644
index 0000000000..7e437a2af7
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/MinecraftPage.h
@@ -0,0 +1,91 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <memory>
+#include <QDialog>
+
+#include "java/JavaChecker.h"
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+class SettingsObject;
+
+namespace Ui
+{
+ class MinecraftPage;
+}
+
+class MinecraftPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit MinecraftPage(QWidget* parent = 0);
+ ~MinecraftPage();
+
+ QString displayName() const override
+ {
+ return tr("Minecraft");
+ }
+ QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("minecraft");
+ }
+ QString id() const override
+ {
+ return "minecraft-settings";
+ }
+ QString helpPage() const override
+ {
+ return "Minecraft-settings";
+ }
+ bool apply() override;
+
+ private:
+ void updateCheckboxStuff();
+ void applySettings();
+ void loadSettings();
+
+ private slots:
+ void on_maximizedCheckBox_clicked(bool checked);
+
+ private:
+ Ui::MinecraftPage* ui;
+};
diff --git a/meshmc/launcher/ui/pages/global/MinecraftPage.ui b/meshmc/launcher/ui/pages/global/MinecraftPage.ui
new file mode 100644
index 0000000000..857b8cfb12
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/MinecraftPage.ui
@@ -0,0 +1,196 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MinecraftPage</class>
+ <widget class="QWidget" name="MinecraftPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>936</width>
+ <height>1134</height>
+ </rect>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <layout class="QVBoxLayout" name="mainLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="tabShape">
+ <enum>QTabWidget::Rounded</enum>
+ </property>
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="minecraftTab">
+ <attribute name="title">
+ <string notr="true">Minecraft</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_3">
+ <item>
+ <widget class="QGroupBox" name="windowSizeGroupBox">
+ <property name="title">
+ <string>Window Size</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_4">
+ <item>
+ <widget class="QCheckBox" name="maximizedCheckBox">
+ <property name="text">
+ <string>Start Minecraft maximized?</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <layout class="QGridLayout" name="gridLayoutWindowSize">
+ <item row="1" column="0">
+ <widget class="QLabel" name="labelWindowHeight">
+ <property name="text">
+ <string>Window hei&amp;ght:</string>
+ </property>
+ <property name="buddy">
+ <cstring>windowHeightSpinBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0">
+ <widget class="QLabel" name="labelWindowWidth">
+ <property name="text">
+ <string>W&amp;indow width:</string>
+ </property>
+ <property name="buddy">
+ <cstring>windowWidthSpinBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QSpinBox" name="windowWidthSpinBox">
+ <property name="minimum">
+ <number>1</number>
+ </property>
+ <property name="maximum">
+ <number>65536</number>
+ </property>
+ <property name="singleStep">
+ <number>1</number>
+ </property>
+ <property name="value">
+ <number>854</number>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QSpinBox" name="windowHeightSpinBox">
+ <property name="minimum">
+ <number>1</number>
+ </property>
+ <property name="maximum">
+ <number>65536</number>
+ </property>
+ <property name="value">
+ <number>480</number>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="nativeLibWorkaroundGroupBox">
+ <property name="title">
+ <string>Native library workarounds</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_5">
+ <item>
+ <widget class="QCheckBox" name="useNativeGLFWCheck">
+ <property name="text">
+ <string>Use system installation of GLFW</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="useNativeOpenALCheck">
+ <property name="text">
+ <string>Use system installation of OpenAL</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="gameTimeGroupBox">
+ <property name="title">
+ <string>Game time</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_6">
+ <item>
+ <widget class="QCheckBox" name="showGameTime">
+ <property name="text">
+ <string>Show time spent playing instances</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="showGlobalGameTime">
+ <property name="text">
+ <string>Show time spent playing across all instances</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="recordGameTime">
+ <property name="text">
+ <string>Record time spent playing instances</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacerMinecraft">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>0</width>
+ <height>0</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>tabWidget</tabstop>
+ <tabstop>maximizedCheckBox</tabstop>
+ <tabstop>windowWidthSpinBox</tabstop>
+ <tabstop>windowHeightSpinBox</tabstop>
+ <tabstop>useNativeGLFWCheck</tabstop>
+ <tabstop>useNativeOpenALCheck</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/global/PasteEEPage.cpp b/meshmc/launcher/ui/pages/global/PasteEEPage.cpp
new file mode 100644
index 0000000000..d52d15f75d
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/PasteEEPage.cpp
@@ -0,0 +1,100 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "PasteEEPage.h"
+#include "ui_PasteEEPage.h"
+
+#include <QMessageBox>
+#include <QFileDialog>
+#include <QStandardPaths>
+#include <QTabBar>
+
+#include "settings/SettingsObject.h"
+#include "tools/BaseProfiler.h"
+#include "Application.h"
+
+PasteEEPage::PasteEEPage(QWidget* parent)
+ : QWidget(parent), ui(new Ui::PasteEEPage)
+{
+ ui->setupUi(this);
+ ui->tabWidget->tabBar()->hide();
+ connect(ui->customAPIkeyEdit, &QLineEdit::textEdited, this,
+ &PasteEEPage::textEdited);
+ loadSettings();
+}
+
+PasteEEPage::~PasteEEPage()
+{
+ delete ui;
+}
+
+void PasteEEPage::loadSettings()
+{
+ auto s = APPLICATION->settings();
+ QString keyToUse = s->get("PasteEEAPIKey").toString();
+ if (keyToUse == "meshmc") {
+ ui->meshmcButton->setChecked(true);
+ } else {
+ ui->customButton->setChecked(true);
+ ui->customAPIkeyEdit->setText(keyToUse);
+ }
+}
+
+void PasteEEPage::applySettings()
+{
+ auto s = APPLICATION->settings();
+
+ QString pasteKeyToUse;
+ if (ui->customButton->isChecked())
+ pasteKeyToUse = ui->customAPIkeyEdit->text();
+ else {
+ pasteKeyToUse = "meshmc";
+ }
+ s->set("PasteEEAPIKey", pasteKeyToUse);
+}
+
+bool PasteEEPage::apply()
+{
+ applySettings();
+ return true;
+}
+
+void PasteEEPage::textEdited(const QString& text)
+{
+ ui->customButton->setChecked(true);
+}
diff --git a/meshmc/launcher/ui/pages/global/PasteEEPage.h b/meshmc/launcher/ui/pages/global/PasteEEPage.h
new file mode 100644
index 0000000000..3eb0aade3a
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/PasteEEPage.h
@@ -0,0 +1,86 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+namespace Ui
+{
+ class PasteEEPage;
+}
+
+class PasteEEPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit PasteEEPage(QWidget* parent = 0);
+ ~PasteEEPage();
+
+ QString displayName() const override
+ {
+ return tr("Log Upload");
+ }
+ QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("log");
+ }
+ QString id() const override
+ {
+ return "log-upload";
+ }
+ QString helpPage() const override
+ {
+ return "Log-Upload";
+ }
+ virtual bool apply() override;
+
+ private:
+ void loadSettings();
+ void applySettings();
+
+ private slots:
+ void textEdited(const QString& text);
+
+ private:
+ Ui::PasteEEPage* ui;
+};
diff --git a/meshmc/launcher/ui/pages/global/PasteEEPage.ui b/meshmc/launcher/ui/pages/global/PasteEEPage.ui
new file mode 100644
index 0000000000..e81a6da78c
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/PasteEEPage.ui
@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>PasteEEPage</class>
+ <widget class="QWidget" name="PasteEEPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>491</width>
+ <height>474</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="tab">
+ <attribute name="title">
+ <string notr="true">Tab 1</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <item>
+ <widget class="QGroupBox" name="groupBox_2">
+ <property name="title">
+ <string>paste.ee API key</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_10">
+ <item>
+ <widget class="QRadioButton" name="meshmcButton">
+ <property name="text">
+ <string>MeshMC key - 12MB &amp;upload limit</string>
+ </property>
+ <attribute name="buttonGroup">
+ <string notr="true">pasteButtonGroup</string>
+ </attribute>
+ </widget>
+ </item>
+ <item>
+ <widget class="QRadioButton" name="customButton">
+ <property name="text">
+ <string>&amp;Your own key - 12MB upload limit:</string>
+ </property>
+ <attribute name="buttonGroup">
+ <string notr="true">pasteButtonGroup</string>
+ </attribute>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLineEdit" name="customAPIkeyEdit">
+ <property name="echoMode">
+ <enum>QLineEdit::Password</enum>
+ </property>
+ <property name="placeholderText">
+ <string>Paste your API key here!</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="Line" name="line">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;a href=&quot;https://paste.ee&quot;&gt;paste.ee&lt;/a&gt; is used by MeshMC for log uploads. If you have a &lt;a href=&quot;https://paste.ee&quot;&gt;paste.ee&lt;/a&gt; account, you can add your API key here and have your uploaded logs paired with your account.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::RichText</enum>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ <property name="openExternalLinks">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>216</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>tabWidget</tabstop>
+ <tabstop>meshmcButton</tabstop>
+ <tabstop>customButton</tabstop>
+ <tabstop>customAPIkeyEdit</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+ <buttongroups>
+ <buttongroup name="pasteButtonGroup"/>
+ </buttongroups>
+</ui>
diff --git a/meshmc/launcher/ui/pages/global/ProxyPage.cpp b/meshmc/launcher/ui/pages/global/ProxyPage.cpp
new file mode 100644
index 0000000000..774d894ff7
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/ProxyPage.cpp
@@ -0,0 +1,126 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "ProxyPage.h"
+#include "ui_ProxyPage.h"
+
+#include <QTabBar>
+
+#include "settings/SettingsObject.h"
+#include "Application.h"
+#include "Application.h"
+
+ProxyPage::ProxyPage(QWidget* parent) : QWidget(parent), ui(new Ui::ProxyPage)
+{
+ ui->setupUi(this);
+ ui->tabWidget->tabBar()->hide();
+ loadSettings();
+ updateCheckboxStuff();
+
+ connect(ui->proxyGroup, &QButtonGroup::idClicked, this,
+ &ProxyPage::proxyChanged);
+}
+
+ProxyPage::~ProxyPage()
+{
+ delete ui;
+}
+
+bool ProxyPage::apply()
+{
+ applySettings();
+ return true;
+}
+
+void ProxyPage::updateCheckboxStuff()
+{
+ ui->proxyAddrBox->setEnabled(!ui->proxyNoneBtn->isChecked() &&
+ !ui->proxyDefaultBtn->isChecked());
+ ui->proxyAuthBox->setEnabled(!ui->proxyNoneBtn->isChecked() &&
+ !ui->proxyDefaultBtn->isChecked());
+}
+
+void ProxyPage::proxyChanged(int)
+{
+ updateCheckboxStuff();
+}
+
+void ProxyPage::applySettings()
+{
+ auto s = APPLICATION->settings();
+
+ // Proxy
+ QString proxyType = "None";
+ if (ui->proxyDefaultBtn->isChecked())
+ proxyType = "Default";
+ else if (ui->proxyNoneBtn->isChecked())
+ proxyType = "None";
+ else if (ui->proxySOCKS5Btn->isChecked())
+ proxyType = "SOCKS5";
+ else if (ui->proxyHTTPBtn->isChecked())
+ proxyType = "HTTP";
+
+ s->set("ProxyType", proxyType);
+ s->set("ProxyAddr", ui->proxyAddrEdit->text());
+ s->set("ProxyPort", ui->proxyPortEdit->value());
+ s->set("ProxyUser", ui->proxyUserEdit->text());
+ s->set("ProxyPass", ui->proxyPassEdit->text());
+
+ APPLICATION->updateProxySettings(
+ proxyType, ui->proxyAddrEdit->text(), ui->proxyPortEdit->value(),
+ ui->proxyUserEdit->text(), ui->proxyPassEdit->text());
+}
+void ProxyPage::loadSettings()
+{
+ auto s = APPLICATION->settings();
+ // Proxy
+ QString proxyType = s->get("ProxyType").toString();
+ if (proxyType == "Default")
+ ui->proxyDefaultBtn->setChecked(true);
+ else if (proxyType == "None")
+ ui->proxyNoneBtn->setChecked(true);
+ else if (proxyType == "SOCKS5")
+ ui->proxySOCKS5Btn->setChecked(true);
+ else if (proxyType == "HTTP")
+ ui->proxyHTTPBtn->setChecked(true);
+
+ ui->proxyAddrEdit->setText(s->get("ProxyAddr").toString());
+ ui->proxyPortEdit->setValue(s->get("ProxyPort").value<uint16_t>());
+ ui->proxyUserEdit->setText(s->get("ProxyUser").toString());
+ ui->proxyPassEdit->setText(s->get("ProxyPass").toString());
+}
diff --git a/meshmc/launcher/ui/pages/global/ProxyPage.h b/meshmc/launcher/ui/pages/global/ProxyPage.h
new file mode 100644
index 0000000000..a6bb9d3b04
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/ProxyPage.h
@@ -0,0 +1,88 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <memory>
+#include <QDialog>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+namespace Ui
+{
+ class ProxyPage;
+}
+
+class ProxyPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit ProxyPage(QWidget* parent = 0);
+ ~ProxyPage();
+
+ QString displayName() const override
+ {
+ return tr("Proxy");
+ }
+ QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("proxy");
+ }
+ QString id() const override
+ {
+ return "proxy-settings";
+ }
+ QString helpPage() const override
+ {
+ return "Proxy-settings";
+ }
+ bool apply() override;
+
+ private:
+ void updateCheckboxStuff();
+ void applySettings();
+ void loadSettings();
+
+ private slots:
+ void proxyChanged(int);
+
+ private:
+ Ui::ProxyPage* ui;
+};
diff --git a/meshmc/launcher/ui/pages/global/ProxyPage.ui b/meshmc/launcher/ui/pages/global/ProxyPage.ui
new file mode 100644
index 0000000000..d7ab0bd38b
--- /dev/null
+++ b/meshmc/launcher/ui/pages/global/ProxyPage.ui
@@ -0,0 +1,203 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ProxyPage</class>
+ <widget class="QWidget" name="ProxyPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>598</width>
+ <height>617</height>
+ </rect>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <widget class="QWidget" name="tabWidgetPage1">
+ <attribute name="title">
+ <string notr="true"/>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QLabel" name="proxyPlainTextWarningLabel_2">
+ <property name="text">
+ <string>This only applies to MeshMC. Minecraft does not accept proxy settings.</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="proxyTypeBox">
+ <property name="title">
+ <string>Type</string>
+ </property>
+ <layout class="QHBoxLayout" name="horizontalLayout_3">
+ <item>
+ <widget class="QRadioButton" name="proxyDefaultBtn">
+ <property name="toolTip">
+ <string>Uses your system's default proxy settings.</string>
+ </property>
+ <property name="text">
+ <string>&amp;Default</string>
+ </property>
+ <attribute name="buttonGroup">
+ <string notr="true">proxyGroup</string>
+ </attribute>
+ </widget>
+ </item>
+ <item>
+ <widget class="QRadioButton" name="proxyNoneBtn">
+ <property name="text">
+ <string>&amp;None</string>
+ </property>
+ <attribute name="buttonGroup">
+ <string notr="true">proxyGroup</string>
+ </attribute>
+ </widget>
+ </item>
+ <item>
+ <widget class="QRadioButton" name="proxySOCKS5Btn">
+ <property name="text">
+ <string>SOC&amp;KS5</string>
+ </property>
+ <attribute name="buttonGroup">
+ <string notr="true">proxyGroup</string>
+ </attribute>
+ </widget>
+ </item>
+ <item>
+ <widget class="QRadioButton" name="proxyHTTPBtn">
+ <property name="text">
+ <string>H&amp;TTP</string>
+ </property>
+ <attribute name="buttonGroup">
+ <string notr="true">proxyGroup</string>
+ </attribute>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="proxyAddrBox">
+ <property name="title">
+ <string>Address and Port</string>
+ </property>
+ <layout class="QHBoxLayout" name="horizontalLayout_2">
+ <item>
+ <widget class="QLineEdit" name="proxyAddrEdit">
+ <property name="placeholderText">
+ <string notr="true">127.0.0.1</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QSpinBox" name="proxyPortEdit">
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ <property name="buttonSymbols">
+ <enum>QAbstractSpinBox::PlusMinus</enum>
+ </property>
+ <property name="maximum">
+ <number>65535</number>
+ </property>
+ <property name="value">
+ <number>8080</number>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="proxyAuthBox">
+ <property name="title">
+ <string>Authentication</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_5">
+ <item row="0" column="1">
+ <widget class="QLineEdit" name="proxyUserEdit"/>
+ </item>
+ <item row="0" column="0">
+ <widget class="QLabel" name="proxyUsernameLabel">
+ <property name="text">
+ <string>Username:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="proxyPasswordLabel">
+ <property name="text">
+ <string>Password:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLineEdit" name="proxyPassEdit">
+ <property name="echoMode">
+ <enum>QLineEdit::Password</enum>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0" colspan="2">
+ <widget class="QLabel" name="proxyPlainTextWarningLabel">
+ <property name="text">
+ <string>Note: Proxy username and password are stored in plain text inside MeshMC's configuration file!</string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+ <buttongroups>
+ <buttongroup name="proxyGroup"/>
+ </buttongroups>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/GameOptionsPage.cpp b/meshmc/launcher/ui/pages/instance/GameOptionsPage.cpp
new file mode 100644
index 0000000000..96087eedae
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/GameOptionsPage.cpp
@@ -0,0 +1,56 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "GameOptionsPage.h"
+#include "ui_GameOptionsPage.h"
+#include "minecraft/MinecraftInstance.h"
+#include "minecraft/gameoptions/GameOptions.h"
+
+GameOptionsPage::GameOptionsPage(MinecraftInstance* inst, QWidget* parent)
+ : QWidget(parent), ui(new Ui::GameOptionsPage)
+{
+ ui->setupUi(this);
+ ui->tabWidget->tabBar()->hide();
+ m_model = inst->gameOptionsModel();
+ ui->optionsView->setModel(m_model.get());
+ auto head = ui->optionsView->header();
+ if (head->count()) {
+ head->setSectionResizeMode(0, QHeaderView::ResizeToContents);
+ for (int i = 1; i < head->count(); i++) {
+ head->setSectionResizeMode(i, QHeaderView::Stretch);
+ }
+ }
+}
+
+GameOptionsPage::~GameOptionsPage()
+{
+ // m_model->save();
+}
+
+void GameOptionsPage::openedImpl()
+{
+ // m_model->observe();
+}
+
+void GameOptionsPage::closedImpl()
+{
+ // m_model->unobserve();
+}
diff --git a/meshmc/launcher/ui/pages/instance/GameOptionsPage.h b/meshmc/launcher/ui/pages/instance/GameOptionsPage.h
new file mode 100644
index 0000000000..92e5296521
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/GameOptionsPage.h
@@ -0,0 +1,86 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+#include <QString>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+namespace Ui
+{
+ class GameOptionsPage;
+}
+
+class GameOptions;
+class MinecraftInstance;
+
+class GameOptionsPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit GameOptionsPage(MinecraftInstance* inst, QWidget* parent = 0);
+ virtual ~GameOptionsPage();
+
+ void openedImpl() override;
+ void closedImpl() override;
+
+ virtual QString displayName() const override
+ {
+ return tr("Game Options");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("settings");
+ }
+ virtual QString id() const override
+ {
+ return "gameoptions";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Game-Options-management";
+ }
+
+ private: // data
+ Ui::GameOptionsPage* ui = nullptr;
+ std::shared_ptr<GameOptions> m_model;
+};
diff --git a/meshmc/launcher/ui/pages/instance/GameOptionsPage.ui b/meshmc/launcher/ui/pages/instance/GameOptionsPage.ui
new file mode 100644
index 0000000000..f0a5ce0ee1
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/GameOptionsPage.ui
@@ -0,0 +1,88 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>GameOptionsPage</class>
+ <widget class="QWidget" name="GameOptionsPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>706</width>
+ <height>575</height>
+ </rect>
+ </property>
+ <layout class="QGridLayout" name="gridLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item row="0" column="0">
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="tab">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <attribute name="title">
+ <string notr="true">Tab 1</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="0" colspan="2">
+ <widget class="QTreeView" name="optionsView">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="acceptDrops">
+ <bool>true</bool>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <property name="selectionMode">
+ <enum>QAbstractItemView::SingleSelection</enum>
+ </property>
+ <property name="selectionBehavior">
+ <enum>QAbstractItemView::SelectRows</enum>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>64</width>
+ <height>64</height>
+ </size>
+ </property>
+ <property name="rootIsDecorated">
+ <bool>false</bool>
+ </property>
+ <attribute name="headerStretchLastSection">
+ <bool>false</bool>
+ </attribute>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>tabWidget</tabstop>
+ <tabstop>optionsView</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.cpp b/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.cpp
new file mode 100644
index 0000000000..7d37415948
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.cpp
@@ -0,0 +1,353 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "InstanceSettingsPage.h"
+#include "ui_InstanceSettingsPage.h"
+
+#include <QFileDialog>
+#include <QDialog>
+#include <QMessageBox>
+
+#include <sys.h>
+
+#include "ui/dialogs/VersionSelectDialog.h"
+#include "ui/widgets/CustomCommands.h"
+
+#include "JavaCommon.h"
+#include "Application.h"
+
+#include "java/JavaInstallList.h"
+#include "FileSystem.h"
+
+InstanceSettingsPage::InstanceSettingsPage(BaseInstance* inst, QWidget* parent)
+ : QWidget(parent), ui(new Ui::InstanceSettingsPage), m_instance(inst)
+{
+ m_settings = inst->settings();
+ ui->setupUi(this);
+ auto sysMB = Sys::getSystemRam() / Sys::mebibyte;
+ ui->maxMemSpinBox->setMaximum(sysMB);
+ connect(ui->openGlobalJavaSettingsButton, &QCommandLinkButton::clicked,
+ this, &InstanceSettingsPage::globalSettingsButtonClicked);
+ connect(APPLICATION, &Application::globalSettingsAboutToOpen, this,
+ &InstanceSettingsPage::applySettings);
+ connect(APPLICATION, &Application::globalSettingsClosed, this,
+ &InstanceSettingsPage::loadSettings);
+ loadSettings();
+}
+
+bool InstanceSettingsPage::shouldDisplay() const
+{
+ return !m_instance->isRunning();
+}
+
+InstanceSettingsPage::~InstanceSettingsPage()
+{
+ delete ui;
+}
+
+void InstanceSettingsPage::globalSettingsButtonClicked(bool)
+{
+ switch (ui->settingsTabs->currentIndex()) {
+ case 0:
+ APPLICATION->ShowGlobalSettings(this, "java-settings");
+ return;
+ case 1:
+ APPLICATION->ShowGlobalSettings(this, "minecraft-settings");
+ return;
+ case 2:
+ APPLICATION->ShowGlobalSettings(this, "custom-commands");
+ return;
+ }
+}
+
+bool InstanceSettingsPage::apply()
+{
+ applySettings();
+ return true;
+}
+
+void InstanceSettingsPage::applySettings()
+{
+ SettingsObject::Lock lock(m_settings);
+
+ // Console
+ bool console = ui->consoleSettingsBox->isChecked();
+ m_settings->set("OverrideConsole", console);
+ if (console) {
+ m_settings->set("ShowConsole", ui->showConsoleCheck->isChecked());
+ m_settings->set("AutoCloseConsole",
+ ui->autoCloseConsoleCheck->isChecked());
+ m_settings->set("ShowConsoleOnError",
+ ui->showConsoleErrorCheck->isChecked());
+ } else {
+ m_settings->reset("ShowConsole");
+ m_settings->reset("AutoCloseConsole");
+ m_settings->reset("ShowConsoleOnError");
+ }
+
+ // Window Size
+ bool window = ui->windowSizeGroupBox->isChecked();
+ m_settings->set("OverrideWindow", window);
+ if (window) {
+ m_settings->set("LaunchMaximized", ui->maximizedCheckBox->isChecked());
+ m_settings->set("MinecraftWinWidth", ui->windowWidthSpinBox->value());
+ m_settings->set("MinecraftWinHeight", ui->windowHeightSpinBox->value());
+ } else {
+ m_settings->reset("LaunchMaximized");
+ m_settings->reset("MinecraftWinWidth");
+ m_settings->reset("MinecraftWinHeight");
+ }
+
+ // Memory
+ bool memory = ui->memoryGroupBox->isChecked();
+ m_settings->set("OverrideMemory", memory);
+ if (memory) {
+ int min = ui->minMemSpinBox->value();
+ int max = ui->maxMemSpinBox->value();
+ if (min < max) {
+ m_settings->set("MinMemAlloc", min);
+ m_settings->set("MaxMemAlloc", max);
+ } else {
+ m_settings->set("MinMemAlloc", max);
+ m_settings->set("MaxMemAlloc", min);
+ }
+ m_settings->set("PermGen", ui->permGenSpinBox->value());
+ } else {
+ m_settings->reset("MinMemAlloc");
+ m_settings->reset("MaxMemAlloc");
+ m_settings->reset("PermGen");
+ }
+
+ // Java Install Settings
+ bool javaInstall = ui->javaSettingsGroupBox->isChecked();
+ m_settings->set("OverrideJavaLocation", javaInstall);
+ if (javaInstall) {
+ m_settings->set("JavaPath", ui->javaPathTextBox->text());
+ } else {
+ m_settings->reset("JavaPath");
+ }
+
+ // Java arguments
+ bool javaArgs = ui->javaArgumentsGroupBox->isChecked();
+ m_settings->set("OverrideJavaArgs", javaArgs);
+ if (javaArgs) {
+ m_settings->set("JvmArgs",
+ ui->jvmArgsTextBox->toPlainText().replace("\n", " "));
+ JavaCommon::checkJVMArgs(m_settings->get("JvmArgs").toString(),
+ this->parentWidget());
+ } else {
+ m_settings->reset("JvmArgs");
+ }
+
+ // old generic 'override both' is removed.
+ m_settings->reset("OverrideJava");
+
+ // Custom Commands
+ bool custcmd = ui->customCommands->checked();
+ m_settings->set("OverrideCommands", custcmd);
+ if (custcmd) {
+ m_settings->set("PreLaunchCommand",
+ ui->customCommands->prelaunchCommand());
+ m_settings->set("WrapperCommand", ui->customCommands->wrapperCommand());
+ m_settings->set("PostExitCommand",
+ ui->customCommands->postexitCommand());
+ } else {
+ m_settings->reset("PreLaunchCommand");
+ m_settings->reset("WrapperCommand");
+ m_settings->reset("PostExitCommand");
+ }
+
+ // Workarounds
+ bool workarounds = ui->nativeWorkaroundsGroupBox->isChecked();
+ m_settings->set("OverrideNativeWorkarounds", workarounds);
+ if (workarounds) {
+ m_settings->set("UseNativeOpenAL",
+ ui->useNativeOpenALCheck->isChecked());
+ m_settings->set("UseNativeGLFW", ui->useNativeGLFWCheck->isChecked());
+ } else {
+ m_settings->reset("UseNativeOpenAL");
+ m_settings->reset("UseNativeGLFW");
+ }
+
+ // Game time
+ bool gameTime = ui->gameTimeGroupBox->isChecked();
+ m_settings->set("OverrideGameTime", gameTime);
+ if (gameTime) {
+ m_settings->set("ShowGameTime", ui->showGameTime->isChecked());
+ m_settings->set("RecordGameTime", ui->recordGameTime->isChecked());
+ } else {
+ m_settings->reset("ShowGameTime");
+ m_settings->reset("RecordGameTime");
+ }
+
+ // Join server on launch
+ bool joinServerOnLaunch = ui->serverJoinGroupBox->isChecked();
+ m_settings->set("JoinServerOnLaunch", joinServerOnLaunch);
+ if (joinServerOnLaunch) {
+ m_settings->set("JoinServerOnLaunchAddress",
+ ui->serverJoinAddress->text());
+ } else {
+ m_settings->reset("JoinServerOnLaunchAddress");
+ }
+}
+
+void InstanceSettingsPage::loadSettings()
+{
+ // Console
+ ui->consoleSettingsBox->setChecked(
+ m_settings->get("OverrideConsole").toBool());
+ ui->showConsoleCheck->setChecked(m_settings->get("ShowConsole").toBool());
+ ui->autoCloseConsoleCheck->setChecked(
+ m_settings->get("AutoCloseConsole").toBool());
+ ui->showConsoleErrorCheck->setChecked(
+ m_settings->get("ShowConsoleOnError").toBool());
+
+ // Window Size
+ ui->windowSizeGroupBox->setChecked(
+ m_settings->get("OverrideWindow").toBool());
+ ui->maximizedCheckBox->setChecked(
+ m_settings->get("LaunchMaximized").toBool());
+ ui->windowWidthSpinBox->setValue(
+ m_settings->get("MinecraftWinWidth").toInt());
+ ui->windowHeightSpinBox->setValue(
+ m_settings->get("MinecraftWinHeight").toInt());
+
+ // Memory
+ ui->memoryGroupBox->setChecked(m_settings->get("OverrideMemory").toBool());
+ int min = m_settings->get("MinMemAlloc").toInt();
+ int max = m_settings->get("MaxMemAlloc").toInt();
+ if (min < max) {
+ ui->minMemSpinBox->setValue(min);
+ ui->maxMemSpinBox->setValue(max);
+ } else {
+ ui->minMemSpinBox->setValue(max);
+ ui->maxMemSpinBox->setValue(min);
+ }
+ ui->permGenSpinBox->setValue(m_settings->get("PermGen").toInt());
+ bool permGenVisible = m_settings->get("PermGenVisible").toBool();
+ ui->permGenSpinBox->setVisible(permGenVisible);
+ ui->labelPermGen->setVisible(permGenVisible);
+ ui->labelPermgenNote->setVisible(permGenVisible);
+
+ // Java Settings
+ bool overrideJava = m_settings->get("OverrideJava").toBool();
+ bool overrideLocation =
+ m_settings->get("OverrideJavaLocation").toBool() || overrideJava;
+ bool overrideArgs =
+ m_settings->get("OverrideJavaArgs").toBool() || overrideJava;
+
+ ui->javaSettingsGroupBox->setChecked(overrideLocation);
+ ui->javaPathTextBox->setText(m_settings->get("JavaPath").toString());
+
+ ui->javaArgumentsGroupBox->setChecked(overrideArgs);
+ ui->jvmArgsTextBox->setPlainText(m_settings->get("JvmArgs").toString());
+
+ // Custom commands
+ ui->customCommands->initialize(
+ true, m_settings->get("OverrideCommands").toBool(),
+ m_settings->get("PreLaunchCommand").toString(),
+ m_settings->get("WrapperCommand").toString(),
+ m_settings->get("PostExitCommand").toString());
+
+ // Workarounds
+ ui->nativeWorkaroundsGroupBox->setChecked(
+ m_settings->get("OverrideNativeWorkarounds").toBool());
+ ui->useNativeGLFWCheck->setChecked(
+ m_settings->get("UseNativeGLFW").toBool());
+ ui->useNativeOpenALCheck->setChecked(
+ m_settings->get("UseNativeOpenAL").toBool());
+
+ // Miscellanous
+ ui->gameTimeGroupBox->setChecked(
+ m_settings->get("OverrideGameTime").toBool());
+ ui->showGameTime->setChecked(m_settings->get("ShowGameTime").toBool());
+ ui->recordGameTime->setChecked(m_settings->get("RecordGameTime").toBool());
+
+ ui->serverJoinGroupBox->setChecked(
+ m_settings->get("JoinServerOnLaunch").toBool());
+ ui->serverJoinAddress->setText(
+ m_settings->get("JoinServerOnLaunchAddress").toString());
+}
+
+void InstanceSettingsPage::on_javaDetectBtn_clicked()
+{
+ JavaInstallPtr java;
+
+ VersionSelectDialog vselect(APPLICATION->javalist().get(),
+ tr("Select a Java version"), this, true);
+ vselect.setResizeOn(2);
+ vselect.exec();
+
+ if (vselect.result() == QDialog::Accepted && vselect.selectedVersion()) {
+ java =
+ std::dynamic_pointer_cast<JavaInstall>(vselect.selectedVersion());
+ ui->javaPathTextBox->setText(java->path);
+ bool visible = java->id.requiresPermGen() &&
+ m_settings->get("OverrideMemory").toBool();
+ ui->permGenSpinBox->setVisible(visible);
+ ui->labelPermGen->setVisible(visible);
+ ui->labelPermgenNote->setVisible(visible);
+ m_settings->set("PermGenVisible", visible);
+ }
+}
+
+void InstanceSettingsPage::on_javaBrowseBtn_clicked()
+{
+ QString raw_path =
+ QFileDialog::getOpenFileName(this, tr("Find Java executable"));
+
+ // do not allow current dir - it's dirty. Do not allow dirs that don't exist
+ if (raw_path.isEmpty()) {
+ return;
+ }
+ QString cooked_path = FS::NormalizePath(raw_path);
+
+ QFileInfo javaInfo(cooked_path);
+ if (!javaInfo.exists() || !javaInfo.isExecutable()) {
+ return;
+ }
+ ui->javaPathTextBox->setText(cooked_path);
+
+ // custom Java could be anything... enable perm gen option
+ ui->permGenSpinBox->setVisible(true);
+ ui->labelPermGen->setVisible(true);
+ ui->labelPermgenNote->setVisible(true);
+ m_settings->set("PermGenVisible", true);
+}
+
+void InstanceSettingsPage::on_javaTestBtn_clicked()
+{
+ if (checker) {
+ return;
+ }
+ checker.reset(new JavaCommon::TestCheck(
+ this, ui->javaPathTextBox->text(),
+ ui->jvmArgsTextBox->toPlainText().replace("\n", " "),
+ ui->minMemSpinBox->value(), ui->maxMemSpinBox->value(),
+ ui->permGenSpinBox->value()));
+ connect(checker.get(), SIGNAL(finished()), SLOT(checkerFinished()));
+ checker->run();
+}
+
+void InstanceSettingsPage::checkerFinished()
+{
+ checker.reset();
+}
diff --git a/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.h b/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.h
new file mode 100644
index 0000000000..7e388c45b8
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.h
@@ -0,0 +1,99 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+#include "java/JavaChecker.h"
+#include "BaseInstance.h"
+#include <QObjectPtr.h>
+#include "ui/pages/BasePage.h"
+#include "JavaCommon.h"
+#include "Application.h"
+
+class JavaChecker;
+namespace Ui
+{
+ class InstanceSettingsPage;
+}
+
+class InstanceSettingsPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit InstanceSettingsPage(BaseInstance* inst, QWidget* parent = 0);
+ virtual ~InstanceSettingsPage();
+ virtual QString displayName() const override
+ {
+ return tr("Settings");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("instance-settings");
+ }
+ virtual QString id() const override
+ {
+ return "settings";
+ }
+ virtual bool apply() override;
+ virtual QString helpPage() const override
+ {
+ return "Instance-settings";
+ }
+ virtual bool shouldDisplay() const override;
+
+ private slots:
+ void on_javaDetectBtn_clicked();
+ void on_javaTestBtn_clicked();
+ void on_javaBrowseBtn_clicked();
+
+ void applySettings();
+ void loadSettings();
+
+ void checkerFinished();
+
+ void globalSettingsButtonClicked(bool checked);
+
+ private:
+ Ui::InstanceSettingsPage* ui;
+ BaseInstance* m_instance;
+ SettingsObjectPtr m_settings;
+ unique_qobject_ptr<JavaCommon::TestCheck> checker;
+};
diff --git a/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.ui b/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.ui
new file mode 100644
index 0000000000..729f8e2a6c
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/InstanceSettingsPage.ui
@@ -0,0 +1,548 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>InstanceSettingsPage</class>
+ <widget class="QWidget" name="InstanceSettingsPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>691</width>
+ <height>581</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QCommandLinkButton" name="openGlobalJavaSettingsButton">
+ <property name="text">
+ <string>Open Global Settings</string>
+ </property>
+ <property name="description">
+ <string>The settings here are overrides for global settings.</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QTabWidget" name="settingsTabs">
+ <property name="tabShape">
+ <enum>QTabWidget::Rounded</enum>
+ </property>
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="minecraftTab">
+ <attribute name="title">
+ <string notr="true">Java</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_5">
+ <item>
+ <widget class="QGroupBox" name="javaSettingsGroupBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Java insta&amp;llation</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="0" column="0" colspan="3">
+ <widget class="QLineEdit" name="javaPathTextBox"/>
+ </item>
+ <item row="1" column="0">
+ <widget class="QPushButton" name="javaDetectBtn">
+ <property name="text">
+ <string>Auto-detect...</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QPushButton" name="javaBrowseBtn">
+ <property name="text">
+ <string>Browse...</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="2">
+ <widget class="QPushButton" name="javaTestBtn">
+ <property name="text">
+ <string>Test</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="memoryGroupBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Memor&amp;y</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="0">
+ <widget class="QLabel" name="labelMinMem">
+ <property name="text">
+ <string>Minimum memory allocation:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QSpinBox" name="maxMemSpinBox">
+ <property name="toolTip">
+ <string>The maximum amount of memory Minecraft is allowed to use.</string>
+ </property>
+ <property name="suffix">
+ <string notr="true"> MiB</string>
+ </property>
+ <property name="minimum">
+ <number>128</number>
+ </property>
+ <property name="maximum">
+ <number>65536</number>
+ </property>
+ <property name="singleStep">
+ <number>128</number>
+ </property>
+ <property name="value">
+ <number>1024</number>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QSpinBox" name="minMemSpinBox">
+ <property name="toolTip">
+ <string>The amount of memory Minecraft is started with.</string>
+ </property>
+ <property name="suffix">
+ <string notr="true"> MiB</string>
+ </property>
+ <property name="minimum">
+ <number>128</number>
+ </property>
+ <property name="maximum">
+ <number>65536</number>
+ </property>
+ <property name="singleStep">
+ <number>128</number>
+ </property>
+ <property name="value">
+ <number>256</number>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <widget class="QSpinBox" name="permGenSpinBox">
+ <property name="toolTip">
+ <string>The amount of memory available to store loaded Java classes.</string>
+ </property>
+ <property name="suffix">
+ <string notr="true"> MiB</string>
+ </property>
+ <property name="minimum">
+ <number>64</number>
+ </property>
+ <property name="maximum">
+ <number>999999999</number>
+ </property>
+ <property name="singleStep">
+ <number>8</number>
+ </property>
+ <property name="value">
+ <number>64</number>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="labelPermGen">
+ <property name="text">
+ <string notr="true">PermGen:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="labelMaxMem">
+ <property name="text">
+ <string>Maximum memory allocation:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="0" colspan="2">
+ <widget class="QLabel" name="labelPermgenNote">
+ <property name="text">
+ <string>Note: Permgen is set automatically by Java 8 and later</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="javaArgumentsGroupBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Java argumen&amp;ts</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_5">
+ <item row="1" column="1">
+ <widget class="QPlainTextEdit" name="jvmArgsTextBox"/>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="javaTab">
+ <attribute name="title">
+ <string>Game windows</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_3">
+ <item>
+ <widget class="QGroupBox" name="windowSizeGroupBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Game Window</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_4">
+ <item>
+ <widget class="QCheckBox" name="maximizedCheckBox">
+ <property name="text">
+ <string>Start Minecraft maximized?</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <layout class="QGridLayout" name="gridLayoutWindowSize">
+ <item row="1" column="0">
+ <widget class="QLabel" name="labelWindowHeight">
+ <property name="text">
+ <string>Window height:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0">
+ <widget class="QLabel" name="labelWindowWidth">
+ <property name="text">
+ <string>Window width:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QSpinBox" name="windowWidthSpinBox">
+ <property name="minimum">
+ <number>1</number>
+ </property>
+ <property name="maximum">
+ <number>65536</number>
+ </property>
+ <property name="singleStep">
+ <number>1</number>
+ </property>
+ <property name="value">
+ <number>854</number>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QSpinBox" name="windowHeightSpinBox">
+ <property name="minimum">
+ <number>1</number>
+ </property>
+ <property name="maximum">
+ <number>65536</number>
+ </property>
+ <property name="value">
+ <number>480</number>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="consoleSettingsBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Conso&amp;le Settings</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <item>
+ <widget class="QCheckBox" name="showConsoleCheck">
+ <property name="text">
+ <string>Show console while the game is running?</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="autoCloseConsoleCheck">
+ <property name="text">
+ <string>Automatically close console when the game quits?</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="showConsoleErrorCheck">
+ <property name="text">
+ <string>Show console when the game crashes?</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacerMinecraft_2">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>88</width>
+ <height>125</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="tab">
+ <attribute name="title">
+ <string>Custom commands</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_6">
+ <item>
+ <widget class="CustomCommands" name="customCommands" native="true"/>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="workaroundsPage">
+ <attribute name="title">
+ <string>Workarounds</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_8">
+ <item>
+ <widget class="QGroupBox" name="nativeWorkaroundsGroupBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Native libraries</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_7">
+ <item>
+ <widget class="QCheckBox" name="useNativeGLFWCheck">
+ <property name="text">
+ <string>Use system installation of GLFW</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="useNativeOpenALCheck">
+ <property name="text">
+ <string>Use system installation of OpenAL</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="miscellaneousPage">
+ <attribute name="title">
+ <string>Miscellaneous</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout_9">
+ <item>
+ <widget class="QGroupBox" name="gameTimeGroupBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Override global game time settings</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_10">
+ <item>
+ <widget class="QCheckBox" name="showGameTime">
+ <property name="text">
+ <string>Show time spent playing this instance</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="recordGameTime">
+ <property name="text">
+ <string>Record time spent playing this instance</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="serverJoinGroupBox">
+ <property name="title">
+ <string>Set a server to join on launch</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_11">
+ <item>
+ <layout class="QGridLayout" name="serverJoinLayout">
+ <item row="0" column="0">
+ <widget class="QLabel" name="serverJoinAddressLabel">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>Server address:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QLineEdit" name="serverJoinAddress"/>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacerMiscellaneous">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>CustomCommands</class>
+ <extends>QWidget</extends>
+ <header>ui/widgets/CustomCommands.h</header>
+ <container>1</container>
+ </customwidget>
+ </customwidgets>
+ <tabstops>
+ <tabstop>openGlobalJavaSettingsButton</tabstop>
+ <tabstop>settingsTabs</tabstop>
+ <tabstop>javaSettingsGroupBox</tabstop>
+ <tabstop>javaPathTextBox</tabstop>
+ <tabstop>javaDetectBtn</tabstop>
+ <tabstop>javaBrowseBtn</tabstop>
+ <tabstop>javaTestBtn</tabstop>
+ <tabstop>memoryGroupBox</tabstop>
+ <tabstop>minMemSpinBox</tabstop>
+ <tabstop>maxMemSpinBox</tabstop>
+ <tabstop>permGenSpinBox</tabstop>
+ <tabstop>javaArgumentsGroupBox</tabstop>
+ <tabstop>jvmArgsTextBox</tabstop>
+ <tabstop>windowSizeGroupBox</tabstop>
+ <tabstop>maximizedCheckBox</tabstop>
+ <tabstop>windowWidthSpinBox</tabstop>
+ <tabstop>windowHeightSpinBox</tabstop>
+ <tabstop>consoleSettingsBox</tabstop>
+ <tabstop>showConsoleCheck</tabstop>
+ <tabstop>autoCloseConsoleCheck</tabstop>
+ <tabstop>showConsoleErrorCheck</tabstop>
+ <tabstop>nativeWorkaroundsGroupBox</tabstop>
+ <tabstop>useNativeGLFWCheck</tabstop>
+ <tabstop>useNativeOpenALCheck</tabstop>
+ <tabstop>showGameTime</tabstop>
+ <tabstop>recordGameTime</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.cpp b/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.cpp
new file mode 100644
index 0000000000..7d12fad0e7
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.cpp
@@ -0,0 +1,74 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "LegacyUpgradePage.h"
+#include "ui_LegacyUpgradePage.h"
+
+#include "InstanceList.h"
+#include "minecraft/legacy/LegacyInstance.h"
+#include "minecraft/legacy/LegacyUpgradeTask.h"
+#include "Application.h"
+
+#include "ui/dialogs/CustomMessageBox.h"
+#include "ui/dialogs/ProgressDialog.h"
+
+LegacyUpgradePage::LegacyUpgradePage(InstancePtr inst, QWidget* parent)
+ : QWidget(parent), ui(new Ui::LegacyUpgradePage), m_inst(inst)
+{
+ ui->setupUi(this);
+}
+
+LegacyUpgradePage::~LegacyUpgradePage()
+{
+ delete ui;
+}
+
+void LegacyUpgradePage::runModalTask(Task* task)
+{
+ connect(task, &Task::failed, [this](QString reason) {
+ CustomMessageBox::selectable(this, tr("Error"), reason,
+ QMessageBox::Warning)
+ ->show();
+ });
+ ProgressDialog loadDialog(this);
+ loadDialog.setSkipButton(true, tr("Abort"));
+ if (loadDialog.execWithTask(task) == QDialog::Accepted) {
+ m_container->requestClose();
+ }
+}
+
+void LegacyUpgradePage::on_upgradeButton_clicked()
+{
+ QString newName = tr("%1 (Migrated)").arg(m_inst->name());
+ auto upgradeTask = new LegacyUpgradeTask(m_inst);
+ upgradeTask->setName(newName);
+ upgradeTask->setGroup(
+ APPLICATION->instances()->getInstanceGroup(m_inst->id()));
+ upgradeTask->setIcon(m_inst->iconKey());
+ unique_qobject_ptr<Task> task(
+ APPLICATION->instances()->wrapInstanceTask(upgradeTask));
+ runModalTask(task.get());
+}
+
+bool LegacyUpgradePage::shouldDisplay() const
+{
+ return !m_inst->isRunning();
+}
diff --git a/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.h b/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.h
new file mode 100644
index 0000000000..bba6c35b5f
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.h
@@ -0,0 +1,87 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+#include "minecraft/legacy/LegacyInstance.h"
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include "tasks/Task.h"
+
+namespace Ui
+{
+ class LegacyUpgradePage;
+}
+
+class LegacyUpgradePage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit LegacyUpgradePage(InstancePtr inst, QWidget* parent = 0);
+ virtual ~LegacyUpgradePage();
+ virtual QString displayName() const override
+ {
+ return tr("Upgrade");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("checkupdate");
+ }
+ virtual QString id() const override
+ {
+ return "upgrade";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Legacy-upgrade";
+ }
+ virtual bool shouldDisplay() const override;
+
+ private slots:
+ void on_upgradeButton_clicked();
+
+ private:
+ void runModalTask(Task* task);
+
+ private:
+ Ui::LegacyUpgradePage* ui;
+ InstancePtr m_inst;
+};
diff --git a/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.ui b/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.ui
new file mode 100644
index 0000000000..3897ce3758
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/LegacyUpgradePage.ui
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>LegacyUpgradePage</class>
+ <widget class="QWidget" name="LegacyUpgradePage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>546</width>
+ <height>405</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_5">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTextBrowser" name="textBrowser">
+ <property name="html">
+ <string>&lt;html&gt;&lt;body&gt;&lt;h1&gt;Upgrade is required&lt;/h1&gt;&lt;p&gt;MeshMC now supports old Minecraft versions and all the required features in the new (OneSix) instance format. As a consequence, the old (Legacy) format has been entirely disabled and old instances need to be upgraded.&lt;/p&gt;&lt;p&gt;The upgrade will create a new instance with the same contents as the current one, in the new format. The original instance will remain untouched, in case anything goes wrong in the process.&lt;/p&gt;&lt;p&gt;Please report any issues on our &lt;a href=&quot;https://github.com/Project-Tick/MeshMC/issues&quot;&gt;github issues page&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;There is also a &lt;a href=&quot;https://discord.gg/GtPmv93&quot;&gt;discord channel for testing here&lt;/a&gt;.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ <property name="openExternalLinks">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCommandLinkButton" name="upgradeButton">
+ <property name="text">
+ <string>Upgrade the instance</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/LogPage.cpp b/meshmc/launcher/ui/pages/instance/LogPage.cpp
new file mode 100644
index 0000000000..8f4f4c11a4
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/LogPage.cpp
@@ -0,0 +1,337 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "LogPage.h"
+#include "ui_LogPage.h"
+
+#include "Application.h"
+
+#include <QIcon>
+#include <QScrollBar>
+#include <QShortcut>
+
+#include "launch/LaunchTask.h"
+#include "settings/Setting.h"
+
+#include "ui/GuiUtil.h"
+#include "ui/ColorCache.h"
+
+#include <BuildConfig.h>
+
+class LogFormatProxyModel : public QIdentityProxyModel
+{
+ public:
+ LogFormatProxyModel(QObject* parent = nullptr) : QIdentityProxyModel(parent)
+ {
+ }
+ QVariant data(const QModelIndex& index, int role) const override
+ {
+ switch (role) {
+ case Qt::FontRole:
+ return m_font;
+ case Qt::ForegroundRole: {
+ MessageLevel::Enum level =
+ (MessageLevel::Enum)QIdentityProxyModel::data(
+ index, LogModel::LevelRole)
+ .toInt();
+ return m_colors->getFront(level);
+ }
+ case Qt::BackgroundRole: {
+ MessageLevel::Enum level =
+ (MessageLevel::Enum)QIdentityProxyModel::data(
+ index, LogModel::LevelRole)
+ .toInt();
+ return m_colors->getBack(level);
+ }
+ default:
+ return QIdentityProxyModel::data(index, role);
+ }
+ }
+
+ void setFont(QFont font)
+ {
+ m_font = font;
+ }
+
+ void setColors(LogColorCache* colors)
+ {
+ m_colors.reset(colors);
+ }
+
+ QModelIndex find(const QModelIndex& start, const QString& value,
+ bool reverse) const
+ {
+ QModelIndex parentIndex = parent(start);
+ auto compare = [&](int r) -> QModelIndex {
+ QModelIndex idx = index(r, start.column(), parentIndex);
+ if (!idx.isValid() || idx == start) {
+ return QModelIndex();
+ }
+ QVariant v = data(idx, Qt::DisplayRole);
+ QString t = v.toString();
+ if (t.contains(value, Qt::CaseInsensitive))
+ return idx;
+ return QModelIndex();
+ };
+ if (reverse) {
+ int from = start.row();
+ int to = 0;
+
+ for (int i = 0; i < 2; ++i) {
+ for (int r = from; (r >= to); --r) {
+ auto idx = compare(r);
+ if (idx.isValid())
+ return idx;
+ }
+ // prepare for the next iteration
+ from = rowCount() - 1;
+ to = start.row();
+ }
+ } else {
+ int from = start.row();
+ int to = rowCount(parentIndex);
+
+ for (int i = 0; i < 2; ++i) {
+ for (int r = from; (r < to); ++r) {
+ auto idx = compare(r);
+ if (idx.isValid())
+ return idx;
+ }
+ // prepare for the next iteration
+ from = 0;
+ to = start.row();
+ }
+ }
+ return QModelIndex();
+ }
+
+ private:
+ QFont m_font;
+ std::unique_ptr<LogColorCache> m_colors;
+};
+
+LogPage::LogPage(InstancePtr instance, QWidget* parent)
+ : QWidget(parent), ui(new Ui::LogPage), m_instance(instance)
+{
+ ui->setupUi(this);
+ ui->tabWidget->tabBar()->hide();
+
+ m_proxy = new LogFormatProxyModel(this);
+ // set up text colors in the log proxy and adapt them to the current theme
+ // foreground and background
+ {
+ auto origForeground =
+ ui->text->palette().color(ui->text->foregroundRole());
+ auto origBackground =
+ ui->text->palette().color(ui->text->backgroundRole());
+ m_proxy->setColors(new LogColorCache(origForeground, origBackground));
+ }
+
+ // set up fonts in the log proxy
+ {
+ QString fontFamily =
+ APPLICATION->settings()->get("ConsoleFont").toString();
+ bool conversionOk = false;
+ int fontSize = APPLICATION->settings()
+ ->get("ConsoleFontSize")
+ .toInt(&conversionOk);
+ if (!conversionOk) {
+ fontSize = 11;
+ }
+ m_proxy->setFont(QFont(fontFamily, fontSize));
+ }
+
+ ui->text->setModel(m_proxy);
+
+ // set up instance and launch process recognition
+ {
+ auto launchTask = m_instance->getLaunchTask();
+ if (launchTask) {
+ setInstanceLaunchTaskChanged(launchTask, true);
+ }
+ connect(m_instance.get(), &BaseInstance::launchTaskChanged, this,
+ &LogPage::onInstanceLaunchTaskChanged);
+ }
+
+ auto findShortcut = new QShortcut(QKeySequence(QKeySequence::Find), this);
+ connect(findShortcut, SIGNAL(activated()), SLOT(findActivated()));
+ auto findNextShortcut =
+ new QShortcut(QKeySequence(QKeySequence::FindNext), this);
+ connect(findNextShortcut, SIGNAL(activated()), SLOT(findNextActivated()));
+ connect(ui->searchBar, SIGNAL(returnPressed()),
+ SLOT(on_findButton_clicked()));
+ auto findPreviousShortcut =
+ new QShortcut(QKeySequence(QKeySequence::FindPrevious), this);
+ connect(findPreviousShortcut, SIGNAL(activated()),
+ SLOT(findPreviousActivated()));
+}
+
+LogPage::~LogPage()
+{
+ delete ui;
+}
+
+void LogPage::modelStateToUI()
+{
+ if (m_model->wrapLines()) {
+ ui->text->setWordWrap(true);
+ ui->wrapCheckbox->setCheckState(Qt::Checked);
+ } else {
+ ui->text->setWordWrap(false);
+ ui->wrapCheckbox->setCheckState(Qt::Unchecked);
+ }
+ if (m_model->suspended()) {
+ ui->trackLogCheckbox->setCheckState(Qt::Unchecked);
+ } else {
+ ui->trackLogCheckbox->setCheckState(Qt::Checked);
+ }
+}
+
+void LogPage::UIToModelState()
+{
+ if (!m_model) {
+ return;
+ }
+ m_model->setLineWrap(ui->wrapCheckbox->checkState() == Qt::Checked);
+ m_model->suspend(ui->trackLogCheckbox->checkState() != Qt::Checked);
+}
+
+void LogPage::setInstanceLaunchTaskChanged(shared_qobject_ptr<LaunchTask> proc,
+ bool initial)
+{
+ m_process = proc;
+ if (m_process) {
+ m_model = proc->getLogModel();
+ m_proxy->setSourceModel(m_model.get());
+ if (initial) {
+ modelStateToUI();
+ } else {
+ UIToModelState();
+ }
+ } else {
+ m_proxy->setSourceModel(nullptr);
+ m_model.reset();
+ }
+}
+
+void LogPage::onInstanceLaunchTaskChanged(shared_qobject_ptr<LaunchTask> proc)
+{
+ setInstanceLaunchTaskChanged(proc, false);
+}
+
+bool LogPage::apply()
+{
+ return true;
+}
+
+bool LogPage::shouldDisplay() const
+{
+ return m_instance->isRunning() || m_proxy->rowCount() > 0;
+}
+
+void LogPage::on_btnPaste_clicked()
+{
+ if (!m_model)
+ return;
+
+ // FIXME: turn this into a proper task and move the upload logic out of
+ // GuiUtil!
+ m_model->append(
+ MessageLevel::MeshMC,
+ QString("%2: Log upload triggered at: %1")
+ .arg(QDateTime::currentDateTime().toString(Qt::RFC2822Date),
+ BuildConfig.MESHMC_NAME));
+ auto url = GuiUtil::uploadPaste(m_model->toPlainText(), this);
+ if (!url.isEmpty()) {
+ m_model->append(MessageLevel::MeshMC,
+ QString("%2: Log uploaded to: %1")
+ .arg(url, BuildConfig.MESHMC_NAME));
+ } else {
+ m_model->append(
+ MessageLevel::Error,
+ QString("%1: Log upload failed!").arg(BuildConfig.MESHMC_NAME));
+ }
+}
+
+void LogPage::on_btnCopy_clicked()
+{
+ if (!m_model)
+ return;
+ m_model->append(
+ MessageLevel::MeshMC,
+ QString("Clipboard copy at: %1")
+ .arg(QDateTime::currentDateTime().toString(Qt::RFC2822Date)));
+ GuiUtil::setClipboardText(m_model->toPlainText());
+}
+
+void LogPage::on_btnClear_clicked()
+{
+ if (!m_model)
+ return;
+ m_model->clear();
+ m_container->refreshContainer();
+}
+
+void LogPage::on_btnBottom_clicked()
+{
+ ui->text->scrollToBottom();
+}
+
+void LogPage::on_trackLogCheckbox_clicked(bool checked)
+{
+ if (!m_model)
+ return;
+ m_model->suspend(!checked);
+}
+
+void LogPage::on_wrapCheckbox_clicked(bool checked)
+{
+ ui->text->setWordWrap(checked);
+ if (!m_model)
+ return;
+ m_model->setLineWrap(checked);
+}
+
+void LogPage::on_findButton_clicked()
+{
+ auto modifiers = QApplication::keyboardModifiers();
+ bool reverse = modifiers & Qt::ShiftModifier;
+ ui->text->findNext(ui->searchBar->text(), reverse);
+}
+
+void LogPage::findNextActivated()
+{
+ ui->text->findNext(ui->searchBar->text(), false);
+}
+
+void LogPage::findPreviousActivated()
+{
+ ui->text->findNext(ui->searchBar->text(), true);
+}
+
+void LogPage::findActivated()
+{
+ // focus the search bar if it doesn't have focus
+ if (!ui->searchBar->hasFocus()) {
+ ui->searchBar->setFocus();
+ ui->searchBar->selectAll();
+ }
+}
diff --git a/meshmc/launcher/ui/pages/instance/LogPage.h b/meshmc/launcher/ui/pages/instance/LogPage.h
new file mode 100644
index 0000000000..1c9b39fc0e
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/LogPage.h
@@ -0,0 +1,110 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+#include "BaseInstance.h"
+#include "launch/LaunchTask.h"
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+namespace Ui
+{
+ class LogPage;
+}
+class QTextCharFormat;
+class LogFormatProxyModel;
+
+class LogPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit LogPage(InstancePtr instance, QWidget* parent = 0);
+ virtual ~LogPage();
+ virtual QString displayName() const override
+ {
+ return tr("Minecraft Log");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("log");
+ }
+ virtual QString id() const override
+ {
+ return "console";
+ }
+ virtual bool apply() override;
+ virtual QString helpPage() const override
+ {
+ return "Minecraft-Logs";
+ }
+ virtual bool shouldDisplay() const override;
+
+ private slots:
+ void on_btnPaste_clicked();
+ void on_btnCopy_clicked();
+ void on_btnClear_clicked();
+ void on_btnBottom_clicked();
+
+ void on_trackLogCheckbox_clicked(bool checked);
+ void on_wrapCheckbox_clicked(bool checked);
+
+ void on_findButton_clicked();
+ void findActivated();
+ void findNextActivated();
+ void findPreviousActivated();
+
+ void onInstanceLaunchTaskChanged(shared_qobject_ptr<LaunchTask> proc);
+
+ private:
+ void modelStateToUI();
+ void UIToModelState();
+ void setInstanceLaunchTaskChanged(shared_qobject_ptr<LaunchTask> proc,
+ bool initial);
+
+ private:
+ Ui::LogPage* ui;
+ InstancePtr m_instance;
+ shared_qobject_ptr<LaunchTask> m_process;
+
+ LogFormatProxyModel* m_proxy;
+ shared_qobject_ptr<LogModel> m_model;
+};
diff --git a/meshmc/launcher/ui/pages/instance/LogPage.ui b/meshmc/launcher/ui/pages/instance/LogPage.ui
new file mode 100644
index 0000000000..ccfc15517b
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/LogPage.ui
@@ -0,0 +1,182 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>LogPage</class>
+ <widget class="QWidget" name="LogPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>825</width>
+ <height>782</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="tab">
+ <attribute name="title">
+ <string notr="true">Tab 1</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="1" column="0" colspan="5">
+ <widget class="LogView" name="text">
+ <property name="undoRedoEnabled">
+ <bool>false</bool>
+ </property>
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ <property name="plainText">
+ <string notr="true"/>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ <property name="centerOnScroll">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0" colspan="5">
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <item>
+ <widget class="QCheckBox" name="trackLogCheckbox">
+ <property name="text">
+ <string>Keep updating</string>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="wrapCheckbox">
+ <property name="text">
+ <string>Wrap lines</string>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnCopy">
+ <property name="toolTip">
+ <string>Copy the whole log into the clipboard</string>
+ </property>
+ <property name="text">
+ <string>&amp;Copy</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnPaste">
+ <property name="toolTip">
+ <string>Upload the log to paste.ee - it will stay online for a month</string>
+ </property>
+ <property name="text">
+ <string>Upload</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnClear">
+ <property name="toolTip">
+ <string>Clear the log</string>
+ </property>
+ <property name="text">
+ <string>Clear</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string>Search:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="2">
+ <widget class="QPushButton" name="findButton">
+ <property name="text">
+ <string>Find</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <widget class="QLineEdit" name="searchBar"/>
+ </item>
+ <item row="2" column="4">
+ <widget class="QPushButton" name="btnBottom">
+ <property name="toolTip">
+ <string>Scroll all the way to bottom</string>
+ </property>
+ <property name="text">
+ <string>Bottom</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="3">
+ <widget class="Line" name="line">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>LogView</class>
+ <extends>QPlainTextEdit</extends>
+ <header>ui/widgets/LogView.h</header>
+ </customwidget>
+ </customwidgets>
+ <tabstops>
+ <tabstop>tabWidget</tabstop>
+ <tabstop>trackLogCheckbox</tabstop>
+ <tabstop>wrapCheckbox</tabstop>
+ <tabstop>btnCopy</tabstop>
+ <tabstop>btnPaste</tabstop>
+ <tabstop>btnClear</tabstop>
+ <tabstop>text</tabstop>
+ <tabstop>searchBar</tabstop>
+ <tabstop>findButton</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/ModFolderPage.cpp b/meshmc/launcher/ui/pages/instance/ModFolderPage.cpp
new file mode 100644
index 0000000000..b894534f5f
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ModFolderPage.cpp
@@ -0,0 +1,407 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "ModFolderPage.h"
+#include "ui_ModFolderPage.h"
+
+#include <QMessageBox>
+#include <QEvent>
+#include <QKeyEvent>
+#include <QAbstractItemModel>
+#include <QMenu>
+#include <QSortFilterProxyModel>
+
+#include "Application.h"
+
+#include "ui/dialogs/CustomMessageBox.h"
+#include "ui/GuiUtil.h"
+
+#include "DesktopServices.h"
+
+#include "minecraft/mod/ModFolderModel.h"
+#include "minecraft/mod/Mod.h"
+#include "minecraft/VersionFilterData.h"
+#include "minecraft/PackProfile.h"
+
+#include "Version.h"
+
+namespace
+{
+ // FIXME: wasteful
+ void RemoveThePrefix(QString& string)
+ {
+ QRegularExpression regex(
+ QStringLiteral("^(([Tt][Hh][eE])|([Tt][eE][Hh])) +"));
+ string.remove(regex);
+ string = string.trimmed();
+ }
+} // namespace
+
+class ModSortProxy : public QSortFilterProxyModel
+{
+ public:
+ explicit ModSortProxy(QObject* parent = 0) : QSortFilterProxyModel(parent)
+ {
+ }
+
+ protected:
+ bool filterAcceptsRow(int source_row,
+ const QModelIndex& source_parent) const override
+ {
+ ModFolderModel* model = qobject_cast<ModFolderModel*>(sourceModel());
+ if (!model) {
+ return false;
+ }
+ const auto& mod = model->at(source_row);
+ if (mod.name().contains(filterRegularExpression())) {
+ return true;
+ }
+ if (mod.description().contains(filterRegularExpression())) {
+ return true;
+ }
+ for (auto& author : mod.authors()) {
+ if (author.contains(filterRegularExpression())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ bool lessThan(const QModelIndex& source_left,
+ const QModelIndex& source_right) const override
+ {
+ ModFolderModel* model = qobject_cast<ModFolderModel*>(sourceModel());
+ if (!model || !source_left.isValid() || !source_right.isValid() ||
+ source_left.column() != source_right.column()) {
+ return QSortFilterProxyModel::lessThan(source_left, source_right);
+ }
+
+ // we are now guaranteed to have two valid indexes in the same column...
+ // we love the provided invariants unconditionally and proceed.
+
+ auto column = (ModFolderModel::Columns)source_left.column();
+ bool invert = false;
+ switch (column) {
+ // GH-2550 - sort by enabled/disabled
+ case ModFolderModel::ActiveColumn: {
+ auto dataL = source_left.data(Qt::CheckStateRole).toBool();
+ auto dataR = source_right.data(Qt::CheckStateRole).toBool();
+ if (dataL != dataR) {
+ return dataL > dataR;
+ }
+ // fallthrough
+ invert = sortOrder() == Qt::DescendingOrder;
+ }
+ // GH-2722 - sort mod names in a way that discards "The" prefixes
+ case ModFolderModel::NameColumn: {
+ auto dataL =
+ model
+ ->data(model->index(source_left.row(),
+ ModFolderModel::NameColumn))
+ .toString();
+ RemoveThePrefix(dataL);
+ auto dataR =
+ model
+ ->data(model->index(source_right.row(),
+ ModFolderModel::NameColumn))
+ .toString();
+ RemoveThePrefix(dataR);
+
+ auto less = dataL.compare(dataR, sortCaseSensitivity());
+ if (less != 0) {
+ return invert ? (less > 0) : (less < 0);
+ }
+ // fallthrough
+ invert = sortOrder() == Qt::DescendingOrder;
+ }
+ // GH-2762 - sort versions by parsing them as versions
+ case ModFolderModel::VersionColumn: {
+ auto dataL = Version(
+ model
+ ->data(model->index(source_left.row(),
+ ModFolderModel::VersionColumn))
+ .toString());
+ auto dataR = Version(
+ model
+ ->data(model->index(source_right.row(),
+ ModFolderModel::VersionColumn))
+ .toString());
+ return invert ? (dataL > dataR) : (dataL < dataR);
+ }
+ default: {
+ return QSortFilterProxyModel::lessThan(source_left,
+ source_right);
+ }
+ }
+ }
+};
+
+ModFolderPage::ModFolderPage(BaseInstance* inst,
+ std::shared_ptr<ModFolderModel> mods, QString id,
+ QString iconName, QString displayName,
+ QString helpPage, QWidget* parent)
+ : QMainWindow(parent), ui(new Ui::ModFolderPage)
+{
+ ui->setupUi(this);
+ ui->actionsToolbar->insertSpacer(ui->actionView_configs);
+
+ m_inst = inst;
+ on_RunningState_changed(m_inst && m_inst->isRunning());
+ m_mods = mods;
+ m_id = id;
+ m_displayName = displayName;
+ m_iconName = iconName;
+ m_helpName = helpPage;
+ m_fileSelectionFilter = "%1 (*.zip *.jar)";
+ m_filterModel = new ModSortProxy(this);
+ m_filterModel->setDynamicSortFilter(true);
+ m_filterModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
+ m_filterModel->setSortCaseSensitivity(Qt::CaseInsensitive);
+ m_filterModel->setSourceModel(m_mods.get());
+ m_filterModel->setFilterKeyColumn(-1);
+ ui->modTreeView->setModel(m_filterModel);
+ ui->modTreeView->installEventFilter(this);
+ ui->modTreeView->sortByColumn(1, Qt::AscendingOrder);
+ ui->modTreeView->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(ui->modTreeView, &ModListView::customContextMenuRequested, this,
+ &ModFolderPage::ShowContextMenu);
+ connect(ui->modTreeView, &ModListView::activated, this,
+ &ModFolderPage::modItemActivated);
+
+ auto smodel = ui->modTreeView->selectionModel();
+ connect(smodel, &QItemSelectionModel::currentChanged, this,
+ &ModFolderPage::modCurrent);
+ connect(ui->filterEdit, &QLineEdit::textChanged, this,
+ &ModFolderPage::on_filterTextChanged);
+ connect(m_inst, &BaseInstance::runningStatusChanged, this,
+ &ModFolderPage::on_RunningState_changed);
+}
+
+void ModFolderPage::modItemActivated(const QModelIndex&)
+{
+ if (!m_controlsEnabled) {
+ return;
+ }
+ auto selection = m_filterModel->mapSelectionToSource(
+ ui->modTreeView->selectionModel()->selection());
+ m_mods->setModStatus(selection.indexes(), ModFolderModel::Toggle);
+}
+
+QMenu* ModFolderPage::createPopupMenu()
+{
+ QMenu* filteredMenu = QMainWindow::createPopupMenu();
+ filteredMenu->removeAction(ui->actionsToolbar->toggleViewAction());
+ return filteredMenu;
+}
+
+void ModFolderPage::ShowContextMenu(const QPoint& pos)
+{
+ auto menu = ui->actionsToolbar->createContextMenu(this, tr("Context menu"));
+ menu->exec(ui->modTreeView->mapToGlobal(pos));
+ delete menu;
+}
+
+void ModFolderPage::openedImpl()
+{
+ m_mods->startWatching();
+}
+
+void ModFolderPage::closedImpl()
+{
+ m_mods->stopWatching();
+}
+
+void ModFolderPage::on_filterTextChanged(const QString& newContents)
+{
+ m_viewFilter = newContents;
+ m_filterModel->setFilterFixedString(m_viewFilter);
+}
+
+CoreModFolderPage::CoreModFolderPage(BaseInstance* inst,
+ std::shared_ptr<ModFolderModel> mods,
+ QString id, QString iconName,
+ QString displayName, QString helpPage,
+ QWidget* parent)
+ : ModFolderPage(inst, mods, id, iconName, displayName, helpPage, parent)
+{
+}
+
+ModFolderPage::~ModFolderPage()
+{
+ m_mods->stopWatching();
+ delete ui;
+}
+
+void ModFolderPage::on_RunningState_changed(bool running)
+{
+ if (m_controlsEnabled == !running) {
+ return;
+ }
+ m_controlsEnabled = !running;
+ ui->actionAdd->setEnabled(m_controlsEnabled);
+ ui->actionDisable->setEnabled(m_controlsEnabled);
+ ui->actionEnable->setEnabled(m_controlsEnabled);
+ ui->actionRemove->setEnabled(m_controlsEnabled);
+}
+
+bool ModFolderPage::shouldDisplay() const
+{
+ return true;
+}
+
+bool CoreModFolderPage::shouldDisplay() const
+{
+ if (ModFolderPage::shouldDisplay()) {
+ auto inst = dynamic_cast<MinecraftInstance*>(m_inst);
+ if (!inst)
+ return true;
+ auto version = inst->getPackProfile();
+ if (!version)
+ return true;
+ if (!version->getComponent("net.minecraftforge")) {
+ return false;
+ }
+ if (!version->getComponent("net.minecraft")) {
+ return false;
+ }
+ if (version->getComponent("net.minecraft")->getReleaseDateTime() <
+ g_VersionFilterData.legacyCutoffDate) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool ModFolderPage::modListFilter(QKeyEvent* keyEvent)
+{
+ switch (keyEvent->key()) {
+ case Qt::Key_Delete:
+ on_actionRemove_triggered();
+ return true;
+ case Qt::Key_Plus:
+ on_actionAdd_triggered();
+ return true;
+ default:
+ break;
+ }
+ return QWidget::eventFilter(ui->modTreeView, keyEvent);
+}
+
+bool ModFolderPage::eventFilter(QObject* obj, QEvent* ev)
+{
+ if (ev->type() != QEvent::KeyPress) {
+ return QWidget::eventFilter(obj, ev);
+ }
+ QKeyEvent* keyEvent = static_cast<QKeyEvent*>(ev);
+ if (obj == ui->modTreeView)
+ return modListFilter(keyEvent);
+ return QWidget::eventFilter(obj, ev);
+}
+
+void ModFolderPage::on_actionAdd_triggered()
+{
+ if (!m_controlsEnabled) {
+ return;
+ }
+ auto list = GuiUtil::BrowseForFiles(
+ m_helpName,
+ tr("Select %1", "Select whatever type of files the page contains. "
+ "Example: 'Loader Mods'")
+ .arg(m_displayName),
+ m_fileSelectionFilter.arg(m_displayName),
+ APPLICATION->settings()->get("CentralModsDir").toString(),
+ this->parentWidget());
+ if (!list.empty()) {
+ for (auto filename : list) {
+ m_mods->installMod(filename);
+ }
+ }
+}
+
+void ModFolderPage::on_actionEnable_triggered()
+{
+ if (!m_controlsEnabled) {
+ return;
+ }
+ auto selection = m_filterModel->mapSelectionToSource(
+ ui->modTreeView->selectionModel()->selection());
+ m_mods->setModStatus(selection.indexes(), ModFolderModel::Enable);
+}
+
+void ModFolderPage::on_actionDisable_triggered()
+{
+ if (!m_controlsEnabled) {
+ return;
+ }
+ auto selection = m_filterModel->mapSelectionToSource(
+ ui->modTreeView->selectionModel()->selection());
+ m_mods->setModStatus(selection.indexes(), ModFolderModel::Disable);
+}
+
+void ModFolderPage::on_actionRemove_triggered()
+{
+ if (!m_controlsEnabled) {
+ return;
+ }
+ auto selection = m_filterModel->mapSelectionToSource(
+ ui->modTreeView->selectionModel()->selection());
+ m_mods->deleteMods(selection.indexes());
+}
+
+void ModFolderPage::on_actionView_configs_triggered()
+{
+ DesktopServices::openDirectory(m_inst->instanceConfigFolder(), true);
+}
+
+void ModFolderPage::on_actionView_Folder_triggered()
+{
+ DesktopServices::openDirectory(m_mods->dir().absolutePath(), true);
+}
+
+void ModFolderPage::modCurrent(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ if (!current.isValid()) {
+ ui->frame->clear();
+ return;
+ }
+ auto sourceCurrent = m_filterModel->mapToSource(current);
+ int row = sourceCurrent.row();
+ Mod& m = m_mods->operator[](row);
+ ui->frame->updateWithMod(m);
+}
diff --git a/meshmc/launcher/ui/pages/instance/ModFolderPage.h b/meshmc/launcher/ui/pages/instance/ModFolderPage.h
new file mode 100644
index 0000000000..59e9cea937
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ModFolderPage.h
@@ -0,0 +1,136 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QMainWindow>
+
+#include "minecraft/MinecraftInstance.h"
+#include "ui/pages/BasePage.h"
+
+#include <Application.h>
+
+class ModFolderModel;
+namespace Ui
+{
+ class ModFolderPage;
+}
+
+class ModFolderPage : public QMainWindow, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit ModFolderPage(BaseInstance* inst,
+ std::shared_ptr<ModFolderModel> mods, QString id,
+ QString iconName, QString displayName,
+ QString helpPage = "", QWidget* parent = 0);
+ virtual ~ModFolderPage();
+
+ void setFilter(const QString& filter)
+ {
+ m_fileSelectionFilter = filter;
+ }
+
+ virtual QString displayName() const override
+ {
+ return m_displayName;
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon(m_iconName);
+ }
+ virtual QString id() const override
+ {
+ return m_id;
+ }
+ virtual QString helpPage() const override
+ {
+ return m_helpName;
+ }
+ virtual bool shouldDisplay() const override;
+
+ virtual void openedImpl() override;
+ virtual void closedImpl() override;
+
+ protected:
+ bool eventFilter(QObject* obj, QEvent* ev) override;
+ bool modListFilter(QKeyEvent* ev);
+ QMenu* createPopupMenu() override;
+
+ protected:
+ BaseInstance* m_inst = nullptr;
+
+ protected:
+ Ui::ModFolderPage* ui = nullptr;
+ std::shared_ptr<ModFolderModel> m_mods;
+ QSortFilterProxyModel* m_filterModel = nullptr;
+ QString m_iconName;
+ QString m_id;
+ QString m_displayName;
+ QString m_helpName;
+ QString m_fileSelectionFilter;
+ QString m_viewFilter;
+ bool m_controlsEnabled = true;
+
+ public slots:
+ void modCurrent(const QModelIndex& current, const QModelIndex& previous);
+
+ private slots:
+ void modItemActivated(const QModelIndex& index);
+ void on_filterTextChanged(const QString& newContents);
+ void on_RunningState_changed(bool running);
+ void on_actionAdd_triggered();
+ void on_actionRemove_triggered();
+ void on_actionEnable_triggered();
+ void on_actionDisable_triggered();
+ void on_actionView_Folder_triggered();
+ void on_actionView_configs_triggered();
+ void ShowContextMenu(const QPoint& pos);
+};
+
+class CoreModFolderPage : public ModFolderPage
+{
+ public:
+ explicit CoreModFolderPage(BaseInstance* inst,
+ std::shared_ptr<ModFolderModel> mods, QString id,
+ QString iconName, QString displayName,
+ QString helpPage = "", QWidget* parent = 0);
+ virtual ~CoreModFolderPage() {}
+ virtual bool shouldDisplay() const;
+};
diff --git a/meshmc/launcher/ui/pages/instance/ModFolderPage.ui b/meshmc/launcher/ui/pages/instance/ModFolderPage.ui
new file mode 100644
index 0000000000..0fb51e84fc
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ModFolderPage.ui
@@ -0,0 +1,164 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ModFolderPage</class>
+ <widget class="QMainWindow" name="ModFolderPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>1042</width>
+ <height>501</height>
+ </rect>
+ </property>
+ <widget class="QWidget" name="centralwidget">
+ <layout class="QGridLayout" name="gridLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item row="4" column="1" colspan="3">
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="1">
+ <widget class="QLineEdit" name="filterEdit">
+ <property name="clearButtonEnabled">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0">
+ <widget class="QLabel" name="filterLabel">
+ <property name="text">
+ <string>Filter:</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="2" column="1" colspan="3">
+ <widget class="MCModInfoFrame" name="frame">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Minimum">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1" colspan="3">
+ <widget class="ModListView" name="modTreeView">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="acceptDrops">
+ <bool>true</bool>
+ </property>
+ <property name="dragDropMode">
+ <enum>QAbstractItemView::DropOnly</enum>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WideBar" name="actionsToolbar">
+ <property name="windowTitle">
+ <string>Actions</string>
+ </property>
+ <property name="toolButtonStyle">
+ <enum>Qt::ToolButtonTextOnly</enum>
+ </property>
+ <attribute name="toolBarArea">
+ <enum>RightToolBarArea</enum>
+ </attribute>
+ <attribute name="toolBarBreak">
+ <bool>false</bool>
+ </attribute>
+ <addaction name="actionAdd"/>
+ <addaction name="separator"/>
+ <addaction name="actionRemove"/>
+ <addaction name="actionEnable"/>
+ <addaction name="actionDisable"/>
+ <addaction name="actionView_configs"/>
+ <addaction name="actionView_Folder"/>
+ </widget>
+ <action name="actionAdd">
+ <property name="text">
+ <string>&amp;Add</string>
+ </property>
+ <property name="toolTip">
+ <string>Add mods</string>
+ </property>
+ </action>
+ <action name="actionRemove">
+ <property name="text">
+ <string>&amp;Remove</string>
+ </property>
+ <property name="toolTip">
+ <string>Remove selected mods</string>
+ </property>
+ </action>
+ <action name="actionEnable">
+ <property name="text">
+ <string>&amp;Enable</string>
+ </property>
+ <property name="toolTip">
+ <string>Enable selected mods</string>
+ </property>
+ </action>
+ <action name="actionDisable">
+ <property name="text">
+ <string>&amp;Disable</string>
+ </property>
+ <property name="toolTip">
+ <string>Disable selected mods</string>
+ </property>
+ </action>
+ <action name="actionView_configs">
+ <property name="text">
+ <string>View &amp;Configs</string>
+ </property>
+ <property name="toolTip">
+ <string>Open the 'config' folder in the system file manager.</string>
+ </property>
+ </action>
+ <action name="actionView_Folder">
+ <property name="text">
+ <string>View &amp;Folder</string>
+ </property>
+ </action>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>ModListView</class>
+ <extends>QTreeView</extends>
+ <header>ui/widgets/ModListView.h</header>
+ </customwidget>
+ <customwidget>
+ <class>MCModInfoFrame</class>
+ <extends>QFrame</extends>
+ <header>ui/widgets/MCModInfoFrame.h</header>
+ <container>1</container>
+ </customwidget>
+ <customwidget>
+ <class>WideBar</class>
+ <extends>QToolBar</extends>
+ <header>ui/widgets/WideBar.h</header>
+ </customwidget>
+ </customwidgets>
+ <tabstops>
+ <tabstop>modTreeView</tabstop>
+ <tabstop>filterEdit</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/NotesPage.cpp b/meshmc/launcher/ui/pages/instance/NotesPage.cpp
new file mode 100644
index 0000000000..8cbb56637a
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/NotesPage.cpp
@@ -0,0 +1,42 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "NotesPage.h"
+#include "ui_NotesPage.h"
+#include <QTabBar>
+
+NotesPage::NotesPage(BaseInstance* inst, QWidget* parent)
+ : QWidget(parent), ui(new Ui::NotesPage), m_inst(inst)
+{
+ ui->setupUi(this);
+ ui->noteEditor->setText(m_inst->notes());
+}
+
+NotesPage::~NotesPage()
+{
+ delete ui;
+}
+
+bool NotesPage::apply()
+{
+ m_inst->setNotes(ui->noteEditor->toPlainText());
+ return true;
+}
diff --git a/meshmc/launcher/ui/pages/instance/NotesPage.h b/meshmc/launcher/ui/pages/instance/NotesPage.h
new file mode 100644
index 0000000000..07265ffa76
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/NotesPage.h
@@ -0,0 +1,83 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+#include "BaseInstance.h"
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+namespace Ui
+{
+ class NotesPage;
+}
+
+class NotesPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit NotesPage(BaseInstance* inst, QWidget* parent = 0);
+ virtual ~NotesPage();
+ virtual QString displayName() const override
+ {
+ return tr("Notes");
+ }
+ virtual QIcon icon() const override
+ {
+ auto icon = APPLICATION->getThemedIcon("notes");
+ if (icon.isNull())
+ icon = APPLICATION->getThemedIcon("news");
+ return icon;
+ }
+ virtual QString id() const override
+ {
+ return "notes";
+ }
+ virtual bool apply() override;
+ virtual QString helpPage() const override
+ {
+ return "Notes";
+ }
+
+ private:
+ Ui::NotesPage* ui;
+ BaseInstance* m_inst;
+};
diff --git a/meshmc/launcher/ui/pages/instance/NotesPage.ui b/meshmc/launcher/ui/pages/instance/NotesPage.ui
new file mode 100644
index 0000000000..67cb261c1b
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/NotesPage.ui
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>NotesPage</class>
+ <widget class="QWidget" name="NotesPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>731</width>
+ <height>538</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTextEdit" name="noteEditor">
+ <property name="verticalScrollBarPolicy">
+ <enum>Qt::ScrollBarAlwaysOn</enum>
+ </property>
+ <property name="tabChangesFocus">
+ <bool>true</bool>
+ </property>
+ <property name="acceptRichText">
+ <bool>false</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextEditable|Qt::TextEditorInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>noteEditor</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/OtherLogsPage.cpp b/meshmc/launcher/ui/pages/instance/OtherLogsPage.cpp
new file mode 100644
index 0000000000..c1c357f622
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/OtherLogsPage.cpp
@@ -0,0 +1,314 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "OtherLogsPage.h"
+#include "ui_OtherLogsPage.h"
+
+#include <QMessageBox>
+
+#include "ui/GuiUtil.h"
+
+#include "RecursiveFileSystemWatcher.h"
+#include <GZip.h>
+#include <FileSystem.h>
+#include <QShortcut>
+
+OtherLogsPage::OtherLogsPage(QString path, IPathMatcher::Ptr fileFilter,
+ QWidget* parent)
+ : QWidget(parent), ui(new Ui::OtherLogsPage), m_path(path),
+ m_fileFilter(fileFilter), m_watcher(new RecursiveFileSystemWatcher(this))
+{
+ ui->setupUi(this);
+ ui->tabWidget->tabBar()->hide();
+
+ m_watcher->setMatcher(fileFilter);
+ m_watcher->setRootDir(QDir::current().absoluteFilePath(m_path));
+
+ connect(m_watcher, &RecursiveFileSystemWatcher::filesChanged, this,
+ &OtherLogsPage::populateSelectLogBox);
+ populateSelectLogBox();
+
+ auto findShortcut = new QShortcut(QKeySequence(QKeySequence::Find), this);
+ connect(findShortcut, &QShortcut::activated, this,
+ &OtherLogsPage::findActivated);
+
+ auto findNextShortcut =
+ new QShortcut(QKeySequence(QKeySequence::FindNext), this);
+ connect(findNextShortcut, &QShortcut::activated, this,
+ &OtherLogsPage::findNextActivated);
+
+ auto findPreviousShortcut =
+ new QShortcut(QKeySequence(QKeySequence::FindPrevious), this);
+ connect(findPreviousShortcut, &QShortcut::activated, this,
+ &OtherLogsPage::findPreviousActivated);
+
+ connect(ui->searchBar, &QLineEdit::returnPressed, this,
+ &OtherLogsPage::on_findButton_clicked);
+}
+
+OtherLogsPage::~OtherLogsPage()
+{
+ delete ui;
+}
+
+void OtherLogsPage::openedImpl()
+{
+ m_watcher->enable();
+}
+void OtherLogsPage::closedImpl()
+{
+ m_watcher->disable();
+}
+
+void OtherLogsPage::populateSelectLogBox()
+{
+ ui->selectLogBox->clear();
+ ui->selectLogBox->addItems(m_watcher->files());
+ if (m_currentFile.isEmpty()) {
+ setControlsEnabled(false);
+ ui->selectLogBox->setCurrentIndex(-1);
+ } else {
+ const int index = ui->selectLogBox->findText(m_currentFile);
+ if (index != -1) {
+ ui->selectLogBox->setCurrentIndex(index);
+ setControlsEnabled(true);
+ } else {
+ setControlsEnabled(false);
+ }
+ }
+}
+
+void OtherLogsPage::on_selectLogBox_currentIndexChanged(const int index)
+{
+ QString file;
+ if (index != -1) {
+ file = ui->selectLogBox->itemText(index);
+ }
+
+ if (file.isEmpty() || !QFile::exists(FS::PathCombine(m_path, file))) {
+ m_currentFile = QString();
+ ui->text->clear();
+ setControlsEnabled(false);
+ } else {
+ m_currentFile = file;
+ on_btnReload_clicked();
+ setControlsEnabled(true);
+ }
+}
+
+void OtherLogsPage::on_btnReload_clicked()
+{
+ if (m_currentFile.isEmpty()) {
+ setControlsEnabled(false);
+ return;
+ }
+ QFile file(FS::PathCombine(m_path, m_currentFile));
+ if (!file.open(QFile::ReadOnly)) {
+ setControlsEnabled(false);
+ ui->btnReload->setEnabled(true); // allow reload
+ m_currentFile = QString();
+ QMessageBox::critical(this, tr("Error"),
+ tr("Unable to open %1 for reading: %2")
+ .arg(m_currentFile, file.errorString()));
+ } else {
+ auto setPlainText = [&](const QString& text) {
+ QString fontFamily =
+ APPLICATION->settings()->get("ConsoleFont").toString();
+ bool conversionOk = false;
+ int fontSize = APPLICATION->settings()
+ ->get("ConsoleFontSize")
+ .toInt(&conversionOk);
+ if (!conversionOk) {
+ fontSize = 11;
+ }
+ QTextDocument* doc = ui->text->document();
+ doc->setDefaultFont(QFont(fontFamily, fontSize));
+ ui->text->setPlainText(text);
+ };
+ auto showTooBig = [&]() {
+ setPlainText(tr("The file (%1) is too big. You may want to open it "
+ "in a viewer optimized "
+ "for large files.")
+ .arg(file.fileName()));
+ };
+ if (file.size() > (1024ll * 1024ll * 12ll)) {
+ showTooBig();
+ return;
+ }
+ QString content;
+ if (file.fileName().endsWith(".gz")) {
+ QByteArray temp;
+ if (!GZip::unzip(file.readAll(), temp)) {
+ setPlainText(
+ tr("The file (%1) is not readable.").arg(file.fileName()));
+ return;
+ }
+ content = QString::fromUtf8(temp);
+ } else {
+ content = QString::fromUtf8(file.readAll());
+ }
+ if (content.size() >= 50000000ll) {
+ showTooBig();
+ return;
+ }
+ setPlainText(content);
+ }
+}
+
+void OtherLogsPage::on_btnPaste_clicked()
+{
+ GuiUtil::uploadPaste(ui->text->toPlainText(), this);
+}
+
+void OtherLogsPage::on_btnCopy_clicked()
+{
+ GuiUtil::setClipboardText(ui->text->toPlainText());
+}
+
+void OtherLogsPage::on_btnDelete_clicked()
+{
+ if (m_currentFile.isEmpty()) {
+ setControlsEnabled(false);
+ return;
+ }
+ if (QMessageBox::question(
+ this, tr("Delete"),
+ tr("Do you really want to delete %1?").arg(m_currentFile),
+ QMessageBox::Yes, QMessageBox::No) == QMessageBox::No) {
+ return;
+ }
+ QFile file(FS::PathCombine(m_path, m_currentFile));
+ if (!file.remove()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Unable to delete %1: %2")
+ .arg(m_currentFile, file.errorString()));
+ }
+}
+
+void OtherLogsPage::on_btnClean_clicked()
+{
+ auto toDelete = m_watcher->files();
+ if (toDelete.isEmpty()) {
+ return;
+ }
+ QMessageBox* messageBox = new QMessageBox(this);
+ messageBox->setWindowTitle(tr("Clean up"));
+ if (toDelete.size() > 5) {
+ messageBox->setText(tr("Do you really want to delete all log files?"));
+ messageBox->setDetailedText(toDelete.join('\n'));
+ } else {
+ messageBox->setText(tr("Do you really want to delete these files?\n%1")
+ .arg(toDelete.join('\n')));
+ }
+ messageBox->setStandardButtons(QMessageBox::Ok | QMessageBox::Cancel);
+ messageBox->setDefaultButton(QMessageBox::Ok);
+ messageBox->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ messageBox->setIcon(QMessageBox::Question);
+ messageBox->setTextInteractionFlags(Qt::TextBrowserInteraction);
+
+ if (messageBox->exec() != QMessageBox::Ok) {
+ return;
+ }
+ QStringList failed;
+ for (auto item : toDelete) {
+ QFile file(FS::PathCombine(m_path, item));
+ if (!file.remove()) {
+ failed.push_back(item);
+ }
+ }
+ if (!failed.empty()) {
+ QMessageBox* messageBox = new QMessageBox(this);
+ messageBox->setWindowTitle(tr("Error"));
+ if (failed.size() > 5) {
+ messageBox->setText(tr("Couldn't delete some files!"));
+ messageBox->setDetailedText(failed.join('\n'));
+ } else {
+ messageBox->setText(
+ tr("Couldn't delete some files:\n%1").arg(failed.join('\n')));
+ }
+ messageBox->setStandardButtons(QMessageBox::Ok);
+ messageBox->setDefaultButton(QMessageBox::Ok);
+ messageBox->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ messageBox->setIcon(QMessageBox::Critical);
+ messageBox->setTextInteractionFlags(Qt::TextBrowserInteraction);
+ messageBox->exec();
+ }
+}
+
+void OtherLogsPage::setControlsEnabled(const bool enabled)
+{
+ ui->btnReload->setEnabled(enabled);
+ ui->btnDelete->setEnabled(enabled);
+ ui->btnCopy->setEnabled(enabled);
+ ui->btnPaste->setEnabled(enabled);
+ ui->text->setEnabled(enabled);
+ ui->btnClean->setEnabled(enabled);
+}
+
+// FIXME: HACK, use LogView instead?
+static void findNext(QPlainTextEdit* _this, const QString& what, bool reverse)
+{
+ _this->find(what, reverse ? QTextDocument::FindFlag::FindBackward
+ : QTextDocument::FindFlag(0));
+}
+
+void OtherLogsPage::on_findButton_clicked()
+{
+ auto modifiers = QApplication::keyboardModifiers();
+ bool reverse = modifiers & Qt::ShiftModifier;
+ findNext(ui->text, ui->searchBar->text(), reverse);
+}
+
+void OtherLogsPage::findNextActivated()
+{
+ findNext(ui->text, ui->searchBar->text(), false);
+}
+
+void OtherLogsPage::findPreviousActivated()
+{
+ findNext(ui->text, ui->searchBar->text(), true);
+}
+
+void OtherLogsPage::findActivated()
+{
+ // focus the search bar if it doesn't have focus
+ if (!ui->searchBar->hasFocus()) {
+ ui->searchBar->setFocus();
+ ui->searchBar->selectAll();
+ }
+}
diff --git a/meshmc/launcher/ui/pages/instance/OtherLogsPage.h b/meshmc/launcher/ui/pages/instance/OtherLogsPage.h
new file mode 100644
index 0000000000..6201055493
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/OtherLogsPage.h
@@ -0,0 +1,105 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include <pathmatcher/IPathMatcher.h>
+
+namespace Ui
+{
+ class OtherLogsPage;
+}
+
+class RecursiveFileSystemWatcher;
+
+class OtherLogsPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit OtherLogsPage(QString path, IPathMatcher::Ptr fileFilter,
+ QWidget* parent = 0);
+ ~OtherLogsPage();
+
+ QString id() const override
+ {
+ return "logs";
+ }
+ QString displayName() const override
+ {
+ return tr("Other logs");
+ }
+ QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("log");
+ }
+ QString helpPage() const override
+ {
+ return "Minecraft-Logs";
+ }
+ void openedImpl() override;
+ void closedImpl() override;
+
+ private slots:
+ void populateSelectLogBox();
+ void on_selectLogBox_currentIndexChanged(const int index);
+ void on_btnReload_clicked();
+ void on_btnPaste_clicked();
+ void on_btnCopy_clicked();
+ void on_btnDelete_clicked();
+ void on_btnClean_clicked();
+
+ void on_findButton_clicked();
+ void findActivated();
+ void findNextActivated();
+ void findPreviousActivated();
+
+ private:
+ void setControlsEnabled(const bool enabled);
+
+ private:
+ Ui::OtherLogsPage* ui;
+ QString m_path;
+ QString m_currentFile;
+ IPathMatcher::Ptr m_fileFilter;
+ RecursiveFileSystemWatcher* m_watcher;
+};
diff --git a/meshmc/launcher/ui/pages/instance/OtherLogsPage.ui b/meshmc/launcher/ui/pages/instance/OtherLogsPage.ui
new file mode 100644
index 0000000000..56ff3b6212
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/OtherLogsPage.ui
@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>OtherLogsPage</class>
+ <widget class="QWidget" name="OtherLogsPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>657</width>
+ <height>538</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="tab">
+ <attribute name="title">
+ <string notr="true">Tab 1</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="2" column="1">
+ <widget class="QLineEdit" name="searchBar"/>
+ </item>
+ <item row="2" column="2">
+ <widget class="QPushButton" name="findButton">
+ <property name="text">
+ <string>Find</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0" colspan="4">
+ <widget class="QPlainTextEdit" name="text">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="verticalScrollBarPolicy">
+ <enum>Qt::ScrollBarAlwaysOn</enum>
+ </property>
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0" colspan="4">
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="3" column="1">
+ <widget class="QPushButton" name="btnCopy">
+ <property name="toolTip">
+ <string>Copy the whole log into the clipboard</string>
+ </property>
+ <property name="text">
+ <string>&amp;Copy</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="3">
+ <widget class="QPushButton" name="btnDelete">
+ <property name="toolTip">
+ <string>Clear the log</string>
+ </property>
+ <property name="text">
+ <string>Delete</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="2">
+ <widget class="QPushButton" name="btnPaste">
+ <property name="toolTip">
+ <string>Upload the log to paste.ee - it will stay online for a month</string>
+ </property>
+ <property name="text">
+ <string>Upload</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="4">
+ <widget class="QPushButton" name="btnClean">
+ <property name="toolTip">
+ <string>Clear the log</string>
+ </property>
+ <property name="text">
+ <string>Clean</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="0">
+ <widget class="QPushButton" name="btnReload">
+ <property name="text">
+ <string>Reload</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0" colspan="5">
+ <widget class="QComboBox" name="selectLogBox">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string>Search:</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>tabWidget</tabstop>
+ <tabstop>selectLogBox</tabstop>
+ <tabstop>btnReload</tabstop>
+ <tabstop>btnCopy</tabstop>
+ <tabstop>btnPaste</tabstop>
+ <tabstop>btnDelete</tabstop>
+ <tabstop>btnClean</tabstop>
+ <tabstop>text</tabstop>
+ <tabstop>searchBar</tabstop>
+ <tabstop>findButton</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/ResourcePackPage.h b/meshmc/launcher/ui/pages/instance/ResourcePackPage.h
new file mode 100644
index 0000000000..6b7de04632
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ResourcePackPage.h
@@ -0,0 +1,45 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ModFolderPage.h"
+#include "ui_ModFolderPage.h"
+
+class ResourcePackPage : public ModFolderPage
+{
+ Q_OBJECT
+ public:
+ explicit ResourcePackPage(MinecraftInstance* instance, QWidget* parent = 0)
+ : ModFolderPage(instance, instance->resourcePackList(), "resourcepacks",
+ "resourcepacks", tr("Resource packs"), "Resource-packs",
+ parent)
+ {
+ ui->actionView_configs->setVisible(false);
+ }
+ virtual ~ResourcePackPage() {}
+
+ virtual bool shouldDisplay() const override
+ {
+ return !m_inst->traits().contains("no-texturepacks") &&
+ !m_inst->traits().contains("texturepacks");
+ }
+};
diff --git a/meshmc/launcher/ui/pages/instance/ScreenshotsPage.cpp b/meshmc/launcher/ui/pages/instance/ScreenshotsPage.cpp
new file mode 100644
index 0000000000..96f09da33a
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ScreenshotsPage.cpp
@@ -0,0 +1,458 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ScreenshotsPage.h"
+#include "ui_ScreenshotsPage.h"
+
+#include <QModelIndex>
+#include <QMutableListIterator>
+#include <QMap>
+#include <QSet>
+#include <QFileIconProvider>
+#include <QFileSystemModel>
+#include <QStyledItemDelegate>
+#include <QLineEdit>
+#include <QRegularExpression>
+#include <QEvent>
+#include <QPainter>
+#include <QClipboard>
+#include <QKeyEvent>
+#include <QMenu>
+
+#include <Application.h>
+
+#include "ui/dialogs/ProgressDialog.h"
+#include "ui/dialogs/CustomMessageBox.h"
+
+#include "net/NetJob.h"
+#include "screenshots/ImgurUpload.h"
+#include "screenshots/ImgurAlbumCreation.h"
+#include "tasks/SequentialTask.h"
+
+#include "RWStorage.h"
+#include <FileSystem.h>
+#include <DesktopServices.h>
+
+typedef RWStorage<QString, QIcon> SharedIconCache;
+typedef std::shared_ptr<SharedIconCache> SharedIconCachePtr;
+
+class ThumbnailingResult : public QObject
+{
+ Q_OBJECT
+ public slots:
+ inline void emitResultsReady(const QString& path)
+ {
+ emit resultsReady(path);
+ }
+ inline void emitResultsFailed(const QString& path)
+ {
+ emit resultsFailed(path);
+ }
+ signals:
+ void resultsReady(const QString& path);
+ void resultsFailed(const QString& path);
+};
+
+class ThumbnailRunnable : public QRunnable
+{
+ public:
+ ThumbnailRunnable(QString path, SharedIconCachePtr cache)
+ {
+ m_path = path;
+ m_cache = cache;
+ }
+ void run()
+ {
+ QFileInfo info(m_path);
+ if (info.isDir())
+ return;
+ if ((info.suffix().compare("png", Qt::CaseInsensitive) != 0))
+ return;
+ int tries = 5;
+ while (tries) {
+ if (!m_cache->stale(m_path))
+ return;
+ QImage image(m_path);
+ if (image.isNull()) {
+ QThread::msleep(500);
+ tries--;
+ continue;
+ }
+ QImage small;
+ if (image.width() > image.height())
+ small = image.scaledToWidth(512).scaledToWidth(
+ 256, Qt::SmoothTransformation);
+ else
+ small = image.scaledToHeight(512).scaledToHeight(
+ 256, Qt::SmoothTransformation);
+ QPoint offset((256 - small.width()) / 2,
+ (256 - small.height()) / 2);
+ QImage square(QSize(256, 256), QImage::Format_ARGB32);
+ square.fill(Qt::transparent);
+
+ QPainter painter(&square);
+ painter.drawImage(offset, small);
+ painter.end();
+
+ QIcon icon(QPixmap::fromImage(square));
+ m_cache->add(m_path, icon);
+ m_resultEmitter.emitResultsReady(m_path);
+ return;
+ }
+ m_resultEmitter.emitResultsFailed(m_path);
+ }
+ QString m_path;
+ SharedIconCachePtr m_cache;
+ ThumbnailingResult m_resultEmitter;
+};
+
+// this is about as elegant and well written as a bag of bricks with scribbles
+// done by insane asylum patients.
+class FilterModel : public QIdentityProxyModel
+{
+ Q_OBJECT
+ public:
+ explicit FilterModel(QObject* parent = 0) : QIdentityProxyModel(parent)
+ {
+ m_thumbnailingPool.setMaxThreadCount(4);
+ m_thumbnailCache = std::make_shared<SharedIconCache>();
+ m_thumbnailCache->add("placeholder", APPLICATION->getThemedIcon(
+ "screenshot-placeholder"));
+ connect(&watcher, SIGNAL(fileChanged(QString)),
+ SLOT(fileChanged(QString)));
+ // FIXME: the watched file set is not updated when files are removed
+ }
+ virtual ~FilterModel()
+ {
+ m_thumbnailingPool.waitForDone(500);
+ }
+ virtual QVariant data(const QModelIndex& proxyIndex,
+ int role = Qt::DisplayRole) const
+ {
+ auto model = sourceModel();
+ if (!model)
+ return QVariant();
+ if (role == Qt::DisplayRole || role == Qt::EditRole) {
+ QVariant result =
+ sourceModel()->data(mapToSource(proxyIndex), role);
+ return result.toString().remove(QRegularExpression("\\.png$"));
+ }
+ if (role == Qt::DecorationRole) {
+ QVariant result = sourceModel()->data(
+ mapToSource(proxyIndex), QFileSystemModel::FilePathRole);
+ QString filePath = result.toString();
+ QIcon temp;
+ if (!watched.contains(filePath)) {
+ ((QFileSystemWatcher&)watcher).addPath(filePath);
+ ((QSet<QString>&)watched).insert(filePath);
+ }
+ if (m_thumbnailCache->get(filePath, temp)) {
+ return temp;
+ }
+ if (!m_failed.contains(filePath)) {
+ ((FilterModel*)this)->thumbnailImage(filePath);
+ }
+ return (m_thumbnailCache->get("placeholder"));
+ }
+ return sourceModel()->data(mapToSource(proxyIndex), role);
+ }
+ virtual bool setData(const QModelIndex& index, const QVariant& value,
+ int role = Qt::EditRole)
+ {
+ auto model = sourceModel();
+ if (!model)
+ return false;
+ if (role != Qt::EditRole)
+ return false;
+ // FIXME: this is a workaround for a bug in QFileSystemModel, where it
+ // doesn't sort after renames
+ {
+ ((QFileSystemModel*)model)->setNameFilterDisables(true);
+ ((QFileSystemModel*)model)->setNameFilterDisables(false);
+ }
+ return model->setData(mapToSource(index), value.toString() + ".png",
+ role);
+ }
+
+ private:
+ void thumbnailImage(QString path)
+ {
+ auto runnable = new ThumbnailRunnable(path, m_thumbnailCache);
+ connect(&(runnable->m_resultEmitter), SIGNAL(resultsReady(QString)),
+ SLOT(thumbnailReady(QString)));
+ connect(&(runnable->m_resultEmitter), SIGNAL(resultsFailed(QString)),
+ SLOT(thumbnailFailed(QString)));
+ ((QThreadPool&)m_thumbnailingPool).start(runnable);
+ }
+ private slots:
+ void thumbnailReady(QString path)
+ {
+ emit layoutChanged();
+ }
+ void thumbnailFailed(QString path)
+ {
+ m_failed.insert(path);
+ }
+ void fileChanged(QString filepath)
+ {
+ m_thumbnailCache->setStale(filepath);
+ thumbnailImage(filepath);
+ // reinsert the path...
+ watcher.removePath(filepath);
+ watcher.addPath(filepath);
+ }
+
+ private:
+ SharedIconCachePtr m_thumbnailCache;
+ QThreadPool m_thumbnailingPool;
+ QSet<QString> m_failed;
+ QSet<QString> watched;
+ QFileSystemWatcher watcher;
+};
+
+class CenteredEditingDelegate : public QStyledItemDelegate
+{
+ public:
+ explicit CenteredEditingDelegate(QObject* parent = 0)
+ : QStyledItemDelegate(parent)
+ {
+ }
+ virtual ~CenteredEditingDelegate() {}
+ virtual QWidget* createEditor(QWidget* parent,
+ const QStyleOptionViewItem& option,
+ const QModelIndex& index) const
+ {
+ auto widget = QStyledItemDelegate::createEditor(parent, option, index);
+ auto foo = dynamic_cast<QLineEdit*>(widget);
+ if (foo) {
+ foo->setAlignment(Qt::AlignHCenter);
+ foo->setFrame(true);
+ foo->setMaximumWidth(192);
+ }
+ return widget;
+ }
+};
+
+ScreenshotsPage::ScreenshotsPage(QString path, QWidget* parent)
+ : QMainWindow(parent), ui(new Ui::ScreenshotsPage)
+{
+ m_model.reset(new QFileSystemModel());
+ m_filterModel.reset(new FilterModel());
+ m_filterModel->setSourceModel(m_model.get());
+ m_model->setFilter(QDir::Files | QDir::Writable | QDir::Readable);
+ m_model->setReadOnly(false);
+ m_model->setNameFilters({"*.png"});
+ m_model->setNameFilterDisables(false);
+ m_folder = path;
+ m_valid = FS::ensureFolderPathExists(m_folder);
+
+ ui->setupUi(this);
+ ui->toolBar->insertSpacer(ui->actionView_Folder);
+
+ ui->listView->setIconSize(QSize(128, 128));
+ ui->listView->setGridSize(QSize(192, 160));
+ ui->listView->setSpacing(9);
+ // ui->listView->setUniformItemSizes(true);
+ ui->listView->setLayoutMode(QListView::Batched);
+ ui->listView->setViewMode(QListView::IconMode);
+ ui->listView->setResizeMode(QListView::Adjust);
+ ui->listView->installEventFilter(this);
+ ui->listView->setEditTriggers(QAbstractItemView::NoEditTriggers);
+ ui->listView->setItemDelegate(new CenteredEditingDelegate(this));
+ ui->listView->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(ui->listView, &QListView::customContextMenuRequested, this,
+ &ScreenshotsPage::ShowContextMenu);
+ connect(ui->listView, SIGNAL(activated(QModelIndex)),
+ SLOT(onItemActivated(QModelIndex)));
+}
+
+bool ScreenshotsPage::eventFilter(QObject* obj, QEvent* evt)
+{
+ if (obj != ui->listView)
+ return QWidget::eventFilter(obj, evt);
+ if (evt->type() != QEvent::KeyPress) {
+ return QWidget::eventFilter(obj, evt);
+ }
+ QKeyEvent* keyEvent = static_cast<QKeyEvent*>(evt);
+ switch (keyEvent->key()) {
+ case Qt::Key_Delete:
+ on_actionDelete_triggered();
+ return true;
+ case Qt::Key_F2:
+ on_actionRename_triggered();
+ return true;
+ default:
+ break;
+ }
+ return QWidget::eventFilter(obj, evt);
+}
+
+ScreenshotsPage::~ScreenshotsPage()
+{
+ delete ui;
+}
+
+void ScreenshotsPage::ShowContextMenu(const QPoint& pos)
+{
+ auto menu = ui->toolBar->createContextMenu(this, tr("Context menu"));
+ menu->exec(ui->listView->mapToGlobal(pos));
+ delete menu;
+}
+
+QMenu* ScreenshotsPage::createPopupMenu()
+{
+ QMenu* filteredMenu = QMainWindow::createPopupMenu();
+ filteredMenu->removeAction(ui->toolBar->toggleViewAction());
+ return filteredMenu;
+}
+
+void ScreenshotsPage::onItemActivated(QModelIndex index)
+{
+ if (!index.isValid())
+ return;
+ auto info = m_model->fileInfo(index);
+ QString fileName = info.absoluteFilePath();
+ DesktopServices::openFile(info.absoluteFilePath());
+}
+
+void ScreenshotsPage::on_actionView_Folder_triggered()
+{
+ DesktopServices::openDirectory(m_folder, true);
+}
+
+void ScreenshotsPage::on_actionUpload_triggered()
+{
+ auto selection = ui->listView->selectionModel()->selectedRows();
+ if (selection.isEmpty())
+ return;
+
+ QList<ScreenShot::Ptr> uploaded;
+ auto job =
+ NetJob::Ptr(new NetJob("Screenshot Upload", APPLICATION->network()));
+ if (selection.size() < 2) {
+ auto item = selection.at(0);
+ auto info = m_model->fileInfo(item);
+ auto screenshot = std::make_shared<ScreenShot>(info);
+ job->addNetAction(ImgurUpload::make(screenshot));
+
+ m_uploadActive = true;
+ ProgressDialog dialog(this);
+
+ if (dialog.execWithTask(job.get()) != QDialog::Accepted) {
+ CustomMessageBox::selectable(
+ this, tr("Failed to upload screenshots!"), tr("Unknown error"),
+ QMessageBox::Warning)
+ ->exec();
+ } else {
+ auto link = screenshot->m_url;
+ QClipboard* clipboard = QApplication::clipboard();
+ clipboard->setText(link);
+ CustomMessageBox::selectable(
+ this, tr("Upload finished"),
+ tr("The <a href=\"%1\">link to the uploaded screenshot</a> "
+ "has been placed in your clipboard.")
+ .arg(link),
+ QMessageBox::Information)
+ ->exec();
+ }
+
+ m_uploadActive = false;
+ return;
+ }
+
+ for (auto item : selection) {
+ auto info = m_model->fileInfo(item);
+ auto screenshot = std::make_shared<ScreenShot>(info);
+ uploaded.push_back(screenshot);
+ job->addNetAction(ImgurUpload::make(screenshot));
+ }
+ SequentialTask task;
+ auto albumTask =
+ NetJob::Ptr(new NetJob("Imgur Album Creation", APPLICATION->network()));
+ auto imgurAlbum = ImgurAlbumCreation::make(uploaded);
+ albumTask->addNetAction(imgurAlbum);
+ task.addTask(job);
+ task.addTask(albumTask);
+ m_uploadActive = true;
+ ProgressDialog prog(this);
+ if (prog.execWithTask(&task) != QDialog::Accepted) {
+ CustomMessageBox::selectable(this, tr("Failed to upload screenshots!"),
+ tr("Unknown error"), QMessageBox::Warning)
+ ->exec();
+ } else {
+ auto link = QString("https://imgur.com/a/%1").arg(imgurAlbum->id());
+ QClipboard* clipboard = QApplication::clipboard();
+ clipboard->setText(link);
+ CustomMessageBox::selectable(
+ this, tr("Upload finished"),
+ tr("The <a href=\"%1\">link to the uploaded album</a> has been "
+ "placed in your clipboard.")
+ .arg(link),
+ QMessageBox::Information)
+ ->exec();
+ }
+ m_uploadActive = false;
+}
+
+void ScreenshotsPage::on_actionDelete_triggered()
+{
+ auto mbox = CustomMessageBox::selectable(
+ this, tr("Are you sure?"),
+ tr("This will delete all selected screenshots."), QMessageBox::Warning,
+ QMessageBox::Yes | QMessageBox::No);
+ std::unique_ptr<QMessageBox> box(mbox);
+
+ if (box->exec() != QMessageBox::Yes)
+ return;
+
+ auto selected = ui->listView->selectionModel()->selectedIndexes();
+ for (auto item : selected) {
+ m_model->remove(item);
+ }
+}
+
+void ScreenshotsPage::on_actionRename_triggered()
+{
+ auto selection = ui->listView->selectionModel()->selectedIndexes();
+ if (selection.isEmpty())
+ return;
+ ui->listView->edit(selection[0]);
+ // TODO: mass renaming
+}
+
+void ScreenshotsPage::openedImpl()
+{
+ if (!m_valid) {
+ m_valid = FS::ensureFolderPathExists(m_folder);
+ }
+ if (m_valid) {
+ QString path = QDir(m_folder).absolutePath();
+ auto idx = m_model->setRootPath(path);
+ if (idx.isValid()) {
+ ui->listView->setModel(m_filterModel.get());
+ ui->listView->setRootIndex(m_filterModel->mapFromSource(idx));
+ } else {
+ ui->listView->setModel(nullptr);
+ }
+ }
+}
+
+#include "ScreenshotsPage.moc"
diff --git a/meshmc/launcher/ui/pages/instance/ScreenshotsPage.h b/meshmc/launcher/ui/pages/instance/ScreenshotsPage.h
new file mode 100644
index 0000000000..87d6dd3772
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ScreenshotsPage.h
@@ -0,0 +1,109 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QMainWindow>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+class QFileSystemModel;
+class QIdentityProxyModel;
+namespace Ui
+{
+ class ScreenshotsPage;
+}
+
+struct ScreenShot;
+class ScreenshotList;
+class ImgurAlbumCreation;
+
+class ScreenshotsPage : public QMainWindow, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit ScreenshotsPage(QString path, QWidget* parent = 0);
+ virtual ~ScreenshotsPage();
+
+ virtual void openedImpl() override;
+
+ enum { NothingDone = 0x42 };
+
+ virtual bool eventFilter(QObject*, QEvent*) override;
+ virtual QString displayName() const override
+ {
+ return tr("Screenshots");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("screenshots");
+ }
+ virtual QString id() const override
+ {
+ return "screenshots";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Screenshots-management";
+ }
+ virtual bool apply() override
+ {
+ return !m_uploadActive;
+ }
+
+ protected:
+ QMenu* createPopupMenu() override;
+
+ private slots:
+ void on_actionUpload_triggered();
+ void on_actionDelete_triggered();
+ void on_actionRename_triggered();
+ void on_actionView_Folder_triggered();
+ void onItemActivated(QModelIndex);
+ void ShowContextMenu(const QPoint& pos);
+
+ private:
+ Ui::ScreenshotsPage* ui;
+ std::shared_ptr<QFileSystemModel> m_model;
+ std::shared_ptr<QIdentityProxyModel> m_filterModel;
+ QString m_folder;
+ bool m_valid = false;
+ bool m_uploadActive = false;
+};
diff --git a/meshmc/launcher/ui/pages/instance/ScreenshotsPage.ui b/meshmc/launcher/ui/pages/instance/ScreenshotsPage.ui
new file mode 100644
index 0000000000..ec46108775
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ScreenshotsPage.ui
@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ScreenshotsPage</class>
+ <widget class="QMainWindow" name="ScreenshotsPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>800</width>
+ <height>600</height>
+ </rect>
+ </property>
+ <widget class="QWidget" name="centralwidget">
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QListView" name="listView">
+ <property name="selectionMode">
+ <enum>QAbstractItemView::ExtendedSelection</enum>
+ </property>
+ <property name="selectionBehavior">
+ <enum>QAbstractItemView::SelectRows</enum>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WideBar" name="toolBar">
+ <property name="windowTitle">
+ <string>Actions</string>
+ </property>
+ <property name="toolButtonStyle">
+ <enum>Qt::ToolButtonTextOnly</enum>
+ </property>
+ <attribute name="toolBarArea">
+ <enum>RightToolBarArea</enum>
+ </attribute>
+ <attribute name="toolBarBreak">
+ <bool>false</bool>
+ </attribute>
+ <addaction name="actionUpload"/>
+ <addaction name="actionDelete"/>
+ <addaction name="actionRename"/>
+ <addaction name="actionView_Folder"/>
+ </widget>
+ <action name="actionUpload">
+ <property name="text">
+ <string>Upload</string>
+ </property>
+ </action>
+ <action name="actionDelete">
+ <property name="text">
+ <string>Delete</string>
+ </property>
+ </action>
+ <action name="actionRename">
+ <property name="text">
+ <string>Rename</string>
+ </property>
+ </action>
+ <action name="actionView_Folder">
+ <property name="text">
+ <string>View Folder</string>
+ </property>
+ </action>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>WideBar</class>
+ <extends>QToolBar</extends>
+ <header>ui/widgets/WideBar.h</header>
+ </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/ServersPage.cpp b/meshmc/launcher/ui/pages/instance/ServersPage.cpp
new file mode 100644
index 0000000000..0538369f99
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ServersPage.cpp
@@ -0,0 +1,734 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ServersPage.h"
+#include "ui_ServersPage.h"
+
+#include <FileSystem.h>
+#include <sstream>
+#include <io/stream_reader.h>
+#include <tag_string.h>
+#include <tag_primitive.h>
+#include <tag_list.h>
+#include <tag_compound.h>
+#include <minecraft/MinecraftInstance.h>
+
+#include <QFileSystemWatcher>
+#include <QMenu>
+
+static const int COLUMN_COUNT = 2; // 3 , TBD: latency and other nice things.
+
+struct Server {
+ // Types
+ enum class AcceptsTextures : int { ASK = 0, ALWAYS = 1, NEVER = 2 };
+
+ // Methods
+ Server()
+ {
+ m_name = QObject::tr("Minecraft Server");
+ }
+ Server(const QString& name, const QString& address)
+ {
+ m_name = name;
+ m_address = address;
+ }
+ Server(nbt::tag_compound& server)
+ {
+ std::string addressStr(server["ip"]);
+ m_address = QString::fromUtf8(addressStr.c_str());
+
+ std::string nameStr(server["name"]);
+ m_name = QString::fromUtf8(nameStr.c_str());
+
+ if (server["icon"]) {
+ std::string base64str(server["icon"]);
+ m_icon = QByteArray::fromBase64(base64str.c_str());
+ }
+
+ if (server.has_key("acceptTextures", nbt::tag_type::Byte)) {
+ bool value = server["acceptTextures"].as<nbt::tag_byte>().get();
+ if (value) {
+ m_acceptsTextures = AcceptsTextures::ALWAYS;
+ } else {
+ m_acceptsTextures = AcceptsTextures::NEVER;
+ }
+ }
+ }
+
+ void serialize(nbt::tag_compound& server)
+ {
+ server.insert("name", m_name.trimmed().toUtf8().toStdString());
+ server.insert("ip", m_address.trimmed().toUtf8().toStdString());
+ if (m_icon.size()) {
+ server.insert("icon", m_icon.toBase64().toStdString());
+ }
+ if (m_acceptsTextures != AcceptsTextures::ASK) {
+ server.insert(
+ "acceptTextures",
+ nbt::tag_byte(m_acceptsTextures == AcceptsTextures::ALWAYS));
+ }
+ }
+
+ // Data - persistent and user changeable
+ QString m_name;
+ QString m_address;
+ AcceptsTextures m_acceptsTextures = AcceptsTextures::ASK;
+
+ // Data - persistent and automatically updated
+ QByteArray m_icon;
+
+ // Data - temporary
+ bool m_checked = false;
+ bool m_up = false;
+ QString m_motd; // https://mctools.org/motd-creator
+ int m_ping = 0;
+ int m_currentPlayers = 0;
+ int m_maxPlayers = 0;
+};
+
+static std::unique_ptr<nbt::tag_compound>
+parseServersDat(const QString& filename)
+{
+ try {
+ QByteArray input = FS::read(filename);
+ std::istringstream foo(std::string(input.constData(), input.size()));
+ auto pair = nbt::io::read_compound(foo);
+
+ if (pair.first != "")
+ return nullptr;
+
+ if (pair.second == nullptr)
+ return nullptr;
+
+ return std::move(pair.second);
+ } catch (...) {
+ return nullptr;
+ }
+}
+
+static bool serializeServerDat(const QString& filename,
+ nbt::tag_compound* levelInfo)
+{
+ try {
+ if (!FS::ensureFilePathExists(filename)) {
+ return false;
+ }
+ std::ostringstream s;
+ nbt::io::write_tag("", *levelInfo, s);
+ QByteArray val(s.str().data(), (int)s.str().size());
+ FS::write(filename, val);
+ return true;
+ } catch (...) {
+ return false;
+ }
+}
+
+class ServersModel : public QAbstractListModel
+{
+ Q_OBJECT
+ public:
+ enum Roles {
+ ServerPtrRole = Qt::UserRole,
+ };
+ explicit ServersModel(const QString& path, QObject* parent = 0)
+ : QAbstractListModel(parent)
+ {
+ m_path = path;
+ m_watcher = new QFileSystemWatcher(this);
+ connect(m_watcher, &QFileSystemWatcher::fileChanged, this,
+ &ServersModel::fileChanged);
+ connect(m_watcher, &QFileSystemWatcher::directoryChanged, this,
+ &ServersModel::dirChanged);
+ m_saveTimer.setSingleShot(true);
+ m_saveTimer.setInterval(5000);
+ connect(&m_saveTimer, &QTimer::timeout, this,
+ &ServersModel::save_internal);
+ }
+ virtual ~ServersModel() {};
+
+ void observe()
+ {
+ if (m_observed) {
+ return;
+ }
+ m_observed = true;
+
+ if (!m_loaded) {
+ load();
+ }
+
+ updateFSObserver();
+ }
+
+ void unobserve()
+ {
+ if (!m_observed) {
+ return;
+ }
+ m_observed = false;
+
+ updateFSObserver();
+ }
+
+ void lock()
+ {
+ if (m_locked) {
+ return;
+ }
+ saveNow();
+
+ m_locked = true;
+ updateFSObserver();
+ }
+
+ void unlock()
+ {
+ if (!m_locked) {
+ return;
+ }
+ m_locked = false;
+
+ updateFSObserver();
+ }
+
+ int addEmptyRow(int position)
+ {
+ if (m_locked) {
+ return -1;
+ }
+ if (position < 0 || position >= rowCount()) {
+ position = rowCount();
+ }
+ beginInsertRows(QModelIndex(), position, position);
+ m_servers.insert(position, Server());
+ endInsertRows();
+ scheduleSave();
+ return position;
+ }
+
+ bool removeRow(int row)
+ {
+ if (m_locked) {
+ return false;
+ }
+ if (row < 0 || row >= rowCount()) {
+ return false;
+ }
+ beginRemoveRows(QModelIndex(), row, row);
+ m_servers.removeAt(row);
+ endRemoveRows(); // does absolutely nothing, the selected server stays
+ // as the next line...
+ scheduleSave();
+ return true;
+ }
+
+ bool moveUp(int row)
+ {
+ if (m_locked) {
+ return false;
+ }
+ if (row <= 0) {
+ return false;
+ }
+ beginMoveRows(QModelIndex(), row, row, QModelIndex(), row - 1);
+ m_servers.swapItemsAt(row - 1, row);
+ endMoveRows();
+ scheduleSave();
+ return true;
+ }
+
+ bool moveDown(int row)
+ {
+ if (m_locked) {
+ return false;
+ }
+ int count = rowCount();
+ if (row + 1 >= count) {
+ return false;
+ }
+ beginMoveRows(QModelIndex(), row, row, QModelIndex(), row + 2);
+ m_servers.swapItemsAt(row + 1, row);
+ endMoveRows();
+ scheduleSave();
+ return true;
+ }
+
+ QVariant headerData(int section, Qt::Orientation orientation,
+ int role) const override
+ {
+ if (section < 0 || section >= COLUMN_COUNT)
+ return QVariant();
+
+ if (role == Qt::DisplayRole) {
+ switch (section) {
+ case 0:
+ return tr("Name");
+ case 1:
+ return tr("Address");
+ case 2:
+ return tr("Latency");
+ }
+ }
+
+ return QAbstractListModel::headerData(section, orientation, role);
+ }
+
+ virtual QVariant data(const QModelIndex& index,
+ int role = Qt::DisplayRole) const override
+ {
+ if (!index.isValid())
+ return QVariant();
+
+ int row = index.row();
+ int column = index.column();
+ if (column < 0 || column >= COLUMN_COUNT)
+ return QVariant();
+
+ if (row < 0 || row >= m_servers.size())
+ return QVariant();
+
+ switch (column) {
+ case 0:
+ switch (role) {
+ case Qt::DecorationRole: {
+ auto& bytes = m_servers[row].m_icon;
+ if (bytes.size()) {
+ QPixmap px;
+ if (px.loadFromData(bytes))
+ return QIcon(px);
+ }
+ return APPLICATION->getThemedIcon("unknown_server");
+ }
+ case Qt::DisplayRole:
+ return m_servers[row].m_name;
+ case ServerPtrRole:
+ return QVariant::fromValue<void*>(
+ (void*)&m_servers[row]);
+ default:
+ return QVariant();
+ }
+ case 1:
+ switch (role) {
+ case Qt::DisplayRole:
+ return m_servers[row].m_address;
+ default:
+ return QVariant();
+ }
+ case 2:
+ switch (role) {
+ case Qt::DisplayRole:
+ return m_servers[row].m_ping;
+ default:
+ return QVariant();
+ }
+ default:
+ return QVariant();
+ }
+ }
+
+ virtual int
+ rowCount(const QModelIndex& parent = QModelIndex()) const override
+ {
+ return m_servers.size();
+ }
+ int columnCount(const QModelIndex& parent) const override
+ {
+ return COLUMN_COUNT;
+ }
+
+ Server* at(int index)
+ {
+ if (index < 0 || index >= rowCount()) {
+ return nullptr;
+ }
+ return &m_servers[index];
+ }
+
+ void setName(int row, const QString& name)
+ {
+ if (m_locked) {
+ return;
+ }
+ auto server = at(row);
+ if (!server || server->m_name == name) {
+ return;
+ }
+ server->m_name = name;
+ emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1));
+ scheduleSave();
+ }
+
+ void setAddress(int row, const QString& address)
+ {
+ if (m_locked) {
+ return;
+ }
+ auto server = at(row);
+ if (!server || server->m_address == address) {
+ return;
+ }
+ server->m_address = address;
+ emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1));
+ scheduleSave();
+ }
+
+ void setAcceptsTextures(int row, Server::AcceptsTextures textures)
+ {
+ if (m_locked) {
+ return;
+ }
+ auto server = at(row);
+ if (!server || server->m_acceptsTextures == textures) {
+ return;
+ }
+ server->m_acceptsTextures = textures;
+ emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1));
+ scheduleSave();
+ }
+
+ void load()
+ {
+ cancelSave();
+ beginResetModel();
+ QList<Server> servers;
+ auto serversDat = parseServersDat(serversPath());
+ if (serversDat) {
+ if (serversDat->has_key("servers", nbt::tag_type::List)) {
+ auto& serversList =
+ serversDat->at("servers").as<nbt::tag_list>();
+ for (auto iter = serversList.begin(); iter != serversList.end();
+ iter++) {
+ auto& serverTag = (*iter).as<nbt::tag_compound>();
+ Server s(serverTag);
+ servers.append(s);
+ }
+ }
+ }
+ m_servers.swap(servers);
+ m_loaded = true;
+ endResetModel();
+ }
+
+ void saveNow()
+ {
+ if (saveIsScheduled()) {
+ save_internal();
+ }
+ }
+
+ public slots:
+ void dirChanged(const QString& path)
+ {
+ qDebug() << "Changed:" << path;
+ load();
+ }
+ void fileChanged(const QString& path)
+ {
+ qDebug() << "Changed:" << path;
+ }
+
+ private slots:
+ void save_internal()
+ {
+ cancelSave();
+ QString path = serversPath();
+ qDebug() << "Server list about to be saved to" << path;
+
+ nbt::tag_compound out;
+ nbt::tag_list list;
+ for (auto& server : m_servers) {
+ nbt::tag_compound serverNbt;
+ server.serialize(serverNbt);
+ list.push_back(std::move(serverNbt));
+ }
+ out.insert("servers", nbt::value(std::move(list)));
+
+ if (!serializeServerDat(path, &out)) {
+ qDebug() << "Failed to save server list:" << path
+ << "Will try again.";
+ scheduleSave();
+ }
+ }
+
+ private:
+ void scheduleSave()
+ {
+ if (!m_loaded) {
+ qDebug() << "Server list should never save if it didn't "
+ "successfully load, path:"
+ << m_path;
+ return;
+ }
+ if (!m_dirty) {
+ m_dirty = true;
+ qDebug() << "Server list save is scheduled for" << m_path;
+ }
+ m_saveTimer.start();
+ }
+
+ void cancelSave()
+ {
+ m_dirty = false;
+ m_saveTimer.stop();
+ }
+
+ bool saveIsScheduled() const
+ {
+ return m_dirty;
+ }
+
+ void updateFSObserver()
+ {
+ bool observingFS = m_watcher->directories().contains(m_path);
+ if (m_observed && m_locked) {
+ if (!observingFS) {
+ qWarning() << "Will watch" << m_path;
+ if (!m_watcher->addPath(m_path)) {
+ qWarning() << "Failed to start watching" << m_path;
+ }
+ }
+ } else {
+ if (observingFS) {
+ qWarning() << "Will stop watching" << m_path;
+ if (!m_watcher->removePath(m_path)) {
+ qWarning() << "Failed to stop watching" << m_path;
+ }
+ }
+ }
+ }
+
+ QString serversPath()
+ {
+ QFileInfo foo(FS::PathCombine(m_path, "servers.dat"));
+ return foo.filePath();
+ }
+
+ private:
+ bool m_loaded = false;
+ bool m_locked = false;
+ bool m_observed = false;
+ bool m_dirty = false;
+ QString m_path;
+ QList<Server> m_servers;
+ QFileSystemWatcher* m_watcher = nullptr;
+ QTimer m_saveTimer;
+};
+
+ServersPage::ServersPage(InstancePtr inst, QWidget* parent)
+ : QMainWindow(parent), ui(new Ui::ServersPage)
+{
+ ui->setupUi(this);
+ m_inst = inst;
+ m_model = new ServersModel(inst->gameRoot(), this);
+ ui->serversView->setIconSize(QSize(64, 64));
+ ui->serversView->setModel(m_model);
+ ui->serversView->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(ui->serversView, &QTreeView::customContextMenuRequested, this,
+ &ServersPage::ShowContextMenu);
+
+ auto head = ui->serversView->header();
+ if (head->count()) {
+ head->setSectionResizeMode(0, QHeaderView::Stretch);
+ for (int i = 1; i < head->count(); i++) {
+ head->setSectionResizeMode(i, QHeaderView::ResizeToContents);
+ }
+ }
+
+ auto selectionModel = ui->serversView->selectionModel();
+ connect(selectionModel, &QItemSelectionModel::currentChanged, this,
+ &ServersPage::currentChanged);
+ connect(m_inst.get(), &MinecraftInstance::runningStatusChanged, this,
+ &ServersPage::on_RunningState_changed);
+ connect(ui->nameLine, &QLineEdit::textEdited, this,
+ &ServersPage::nameEdited);
+ connect(ui->addressLine, &QLineEdit::textEdited, this,
+ &ServersPage::addressEdited);
+ connect(ui->resourceComboBox, SIGNAL(currentIndexChanged(int)), this,
+ SLOT(resourceIndexChanged(int)));
+ connect(m_model, &QAbstractItemModel::rowsRemoved, this,
+ &ServersPage::rowsRemoved);
+
+ m_locked = m_inst->isRunning();
+ if (m_locked) {
+ m_model->lock();
+ }
+
+ updateState();
+}
+
+ServersPage::~ServersPage()
+{
+ m_model->saveNow();
+ delete ui;
+}
+
+void ServersPage::ShowContextMenu(const QPoint& pos)
+{
+ auto menu = ui->toolBar->createContextMenu(this, tr("Context menu"));
+ menu->exec(ui->serversView->mapToGlobal(pos));
+ delete menu;
+}
+
+QMenu* ServersPage::createPopupMenu()
+{
+ QMenu* filteredMenu = QMainWindow::createPopupMenu();
+ filteredMenu->removeAction(ui->toolBar->toggleViewAction());
+ return filteredMenu;
+}
+
+void ServersPage::on_RunningState_changed(bool running)
+{
+ if (m_locked == running) {
+ return;
+ }
+ m_locked = running;
+ if (m_locked) {
+ m_model->lock();
+ } else {
+ m_model->unlock();
+ }
+ updateState();
+}
+
+void ServersPage::currentChanged(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ int nextServer = -1;
+ if (!current.isValid()) {
+ nextServer = -1;
+ } else {
+ nextServer = current.row();
+ }
+ currentServer = nextServer;
+ updateState();
+}
+
+// WARNING: this is here because currentChanged is not accurate when removing
+// rows. the current item needs to be fixed up after removal.
+void ServersPage::rowsRemoved(const QModelIndex& parent, int first, int last)
+{
+ if (currentServer < first) {
+ // current was before the removal
+ return;
+ } else if (currentServer >= first && currentServer <= last) {
+ // current got removed...
+ return;
+ } else {
+ // current was past the removal
+ int count = last - first + 1;
+ currentServer -= count;
+ }
+}
+
+void ServersPage::nameEdited(const QString& name)
+{
+ m_model->setName(currentServer, name);
+}
+
+void ServersPage::addressEdited(const QString& address)
+{
+ m_model->setAddress(currentServer, address);
+}
+
+void ServersPage::resourceIndexChanged(int index)
+{
+ auto acceptsTextures = Server::AcceptsTextures(index);
+ m_model->setAcceptsTextures(currentServer, acceptsTextures);
+}
+
+void ServersPage::updateState()
+{
+ auto server = m_model->at(currentServer);
+
+ bool serverEditEnabled = server && !m_locked;
+ ui->addressLine->setEnabled(serverEditEnabled);
+ ui->nameLine->setEnabled(serverEditEnabled);
+ ui->resourceComboBox->setEnabled(serverEditEnabled);
+ ui->actionMove_Down->setEnabled(serverEditEnabled);
+ ui->actionMove_Up->setEnabled(serverEditEnabled);
+ ui->actionRemove->setEnabled(serverEditEnabled);
+ ui->actionJoin->setEnabled(serverEditEnabled);
+
+ if (server) {
+ ui->addressLine->setText(server->m_address);
+ ui->nameLine->setText(server->m_name);
+ ui->resourceComboBox->setCurrentIndex(int(server->m_acceptsTextures));
+ } else {
+ ui->addressLine->setText(QString());
+ ui->nameLine->setText(QString());
+ ui->resourceComboBox->setCurrentIndex(0);
+ }
+
+ ui->actionAdd->setDisabled(m_locked);
+}
+
+void ServersPage::openedImpl()
+{
+ m_model->observe();
+}
+
+void ServersPage::closedImpl()
+{
+ m_model->unobserve();
+}
+
+void ServersPage::on_actionAdd_triggered()
+{
+ int position = m_model->addEmptyRow(currentServer + 1);
+ if (position < 0) {
+ return;
+ }
+ // select the new row
+ ui->serversView->selectionModel()->setCurrentIndex(
+ m_model->index(position), QItemSelectionModel::SelectCurrent |
+ QItemSelectionModel::Clear |
+ QItemSelectionModel::Rows);
+ currentServer = position;
+}
+
+void ServersPage::on_actionRemove_triggered()
+{
+ m_model->removeRow(currentServer);
+}
+
+void ServersPage::on_actionMove_Up_triggered()
+{
+ if (m_model->moveUp(currentServer)) {
+ currentServer--;
+ }
+}
+
+void ServersPage::on_actionMove_Down_triggered()
+{
+ if (m_model->moveDown(currentServer)) {
+ currentServer++;
+ }
+}
+
+void ServersPage::on_actionJoin_triggered()
+{
+ const auto& address = m_model->at(currentServer)->m_address;
+ APPLICATION->launch(m_inst, true, nullptr,
+ std::make_shared<MinecraftServerTarget>(
+ MinecraftServerTarget::parse(address)));
+}
+
+#include "ServersPage.moc"
diff --git a/meshmc/launcher/ui/pages/instance/ServersPage.h b/meshmc/launcher/ui/pages/instance/ServersPage.h
new file mode 100644
index 0000000000..bd04b1c338
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ServersPage.h
@@ -0,0 +1,117 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QMainWindow>
+#include <QString>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+
+namespace Ui
+{
+ class ServersPage;
+}
+
+struct Server;
+class ServersModel;
+class MinecraftInstance;
+
+class ServersPage : public QMainWindow, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit ServersPage(InstancePtr inst, QWidget* parent = 0);
+ virtual ~ServersPage();
+
+ void openedImpl() override;
+ void closedImpl() override;
+
+ virtual QString displayName() const override
+ {
+ return tr("Servers");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("unknown_server");
+ }
+ virtual QString id() const override
+ {
+ return "servers";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Servers-management";
+ }
+
+ protected:
+ QMenu* createPopupMenu() override;
+
+ private:
+ void updateState();
+ void scheduleSave();
+ bool saveIsScheduled() const;
+
+ private slots:
+ void currentChanged(const QModelIndex& current,
+ const QModelIndex& previous);
+ void rowsRemoved(const QModelIndex& parent, int first, int last);
+
+ void on_actionAdd_triggered();
+ void on_actionRemove_triggered();
+ void on_actionMove_Up_triggered();
+ void on_actionMove_Down_triggered();
+ void on_actionJoin_triggered();
+
+ void on_RunningState_changed(bool running);
+
+ void nameEdited(const QString& name);
+ void addressEdited(const QString& address);
+ void resourceIndexChanged(int index);
+
+ void ShowContextMenu(const QPoint& pos);
+
+ private: // data
+ int currentServer = -1;
+ bool m_locked = true;
+ Ui::ServersPage* ui = nullptr;
+ ServersModel* m_model = nullptr;
+ InstancePtr m_inst = nullptr;
+};
diff --git a/meshmc/launcher/ui/pages/instance/ServersPage.ui b/meshmc/launcher/ui/pages/instance/ServersPage.ui
new file mode 100644
index 0000000000..e8f79cf2e4
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ServersPage.ui
@@ -0,0 +1,194 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ServersPage</class>
+ <widget class="QMainWindow" name="ServersPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>1318</width>
+ <height>879</height>
+ </rect>
+ </property>
+ <widget class="QWidget" name="centralwidget">
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTreeView" name="serversView">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="acceptDrops">
+ <bool>true</bool>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <property name="selectionMode">
+ <enum>QAbstractItemView::SingleSelection</enum>
+ </property>
+ <property name="selectionBehavior">
+ <enum>QAbstractItemView::SelectRows</enum>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>64</width>
+ <height>64</height>
+ </size>
+ </property>
+ <property name="rootIsDecorated">
+ <bool>false</bool>
+ </property>
+ <attribute name="headerStretchLastSection">
+ <bool>false</bool>
+ </attribute>
+ </widget>
+ </item>
+ <item>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <property name="leftMargin">
+ <number>6</number>
+ </property>
+ <property name="rightMargin">
+ <number>6</number>
+ </property>
+ <item row="0" column="0">
+ <widget class="QLabel" name="nameLabel">
+ <property name="text">
+ <string>&amp;Name</string>
+ </property>
+ <property name="buddy">
+ <cstring>nameLine</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QLineEdit" name="nameLine"/>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="addressLabel">
+ <property name="text">
+ <string>Address</string>
+ </property>
+ <property name="buddy">
+ <cstring>addressLine</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLineEdit" name="addressLine"/>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="resourcesLabel">
+ <property name="text">
+ <string>Reso&amp;urces</string>
+ </property>
+ <property name="buddy">
+ <cstring>resourceComboBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <widget class="QComboBox" name="resourceComboBox">
+ <item>
+ <property name="text">
+ <string>Ask to download</string>
+ </property>
+ </item>
+ <item>
+ <property name="text">
+ <string>Always download</string>
+ </property>
+ </item>
+ <item>
+ <property name="text">
+ <string>Never download</string>
+ </property>
+ </item>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WideBar" name="toolBar">
+ <property name="windowTitle">
+ <string>Actions</string>
+ </property>
+ <property name="allowedAreas">
+ <set>Qt::LeftToolBarArea|Qt::RightToolBarArea</set>
+ </property>
+ <property name="toolButtonStyle">
+ <enum>Qt::ToolButtonTextOnly</enum>
+ </property>
+ <property name="floatable">
+ <bool>false</bool>
+ </property>
+ <attribute name="toolBarArea">
+ <enum>RightToolBarArea</enum>
+ </attribute>
+ <attribute name="toolBarBreak">
+ <bool>false</bool>
+ </attribute>
+ <addaction name="actionAdd"/>
+ <addaction name="actionRemove"/>
+ <addaction name="actionMove_Up"/>
+ <addaction name="actionMove_Down"/>
+ <addaction name="actionJoin"/>
+ </widget>
+ <action name="actionAdd">
+ <property name="text">
+ <string>Add</string>
+ </property>
+ </action>
+ <action name="actionRemove">
+ <property name="text">
+ <string>Remove</string>
+ </property>
+ </action>
+ <action name="actionMove_Up">
+ <property name="text">
+ <string>Move Up</string>
+ </property>
+ </action>
+ <action name="actionMove_Down">
+ <property name="text">
+ <string>Move Down</string>
+ </property>
+ </action>
+ <action name="actionJoin">
+ <property name="text">
+ <string>Join</string>
+ </property>
+ </action>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>WideBar</class>
+ <extends>QToolBar</extends>
+ <header>ui/widgets/WideBar.h</header>
+ </customwidget>
+ </customwidgets>
+ <tabstops>
+ <tabstop>serversView</tabstop>
+ <tabstop>nameLine</tabstop>
+ <tabstop>addressLine</tabstop>
+ <tabstop>resourceComboBox</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/ShaderPackPage.h b/meshmc/launcher/ui/pages/instance/ShaderPackPage.h
new file mode 100644
index 0000000000..b2bd61ccdd
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/ShaderPackPage.h
@@ -0,0 +1,44 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ModFolderPage.h"
+#include "ui_ModFolderPage.h"
+
+class ShaderPackPage : public ModFolderPage
+{
+ Q_OBJECT
+ public:
+ explicit ShaderPackPage(MinecraftInstance* instance, QWidget* parent = 0)
+ : ModFolderPage(instance, instance->shaderPackList(), "shaderpacks",
+ "shaderpacks", tr("Shader packs"), "Resource-packs",
+ parent)
+ {
+ ui->actionView_configs->setVisible(false);
+ }
+ virtual ~ShaderPackPage() {}
+
+ virtual bool shouldDisplay() const override
+ {
+ return true;
+ }
+};
diff --git a/meshmc/launcher/ui/pages/instance/TexturePackPage.h b/meshmc/launcher/ui/pages/instance/TexturePackPage.h
new file mode 100644
index 0000000000..43fd03bd30
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/TexturePackPage.h
@@ -0,0 +1,44 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ModFolderPage.h"
+#include "ui_ModFolderPage.h"
+
+class TexturePackPage : public ModFolderPage
+{
+ Q_OBJECT
+ public:
+ explicit TexturePackPage(MinecraftInstance* instance, QWidget* parent = 0)
+ : ModFolderPage(instance, instance->texturePackList(), "texturepacks",
+ "resourcepacks", tr("Texture packs"), "Texture-packs",
+ parent)
+ {
+ ui->actionView_configs->setVisible(false);
+ }
+ virtual ~TexturePackPage() {}
+
+ virtual bool shouldDisplay() const override
+ {
+ return m_inst->traits().contains("texturepacks");
+ }
+};
diff --git a/meshmc/launcher/ui/pages/instance/VersionPage.cpp b/meshmc/launcher/ui/pages/instance/VersionPage.cpp
new file mode 100644
index 0000000000..5ad383b79c
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/VersionPage.cpp
@@ -0,0 +1,809 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "Application.h"
+
+#include <QMessageBox>
+#include <QLabel>
+#include <QEvent>
+#include <QKeyEvent>
+#include <QMenu>
+#include <QAbstractItemModel>
+#include <QMessageBox>
+#include <QListView>
+#include <QString>
+#include <QUrl>
+
+#include "VersionPage.h"
+#include "ui_VersionPage.h"
+
+#include "ui/dialogs/CustomMessageBox.h"
+#include "ui/dialogs/VersionSelectDialog.h"
+#include "ui/dialogs/NewComponentDialog.h"
+#include "ui/dialogs/ProgressDialog.h"
+
+#include "ui/GuiUtil.h"
+
+#include "minecraft/PackProfile.h"
+#include "minecraft/auth/AccountList.h"
+#include "minecraft/mod/Mod.h"
+#include "icons/IconList.h"
+#include "Exception.h"
+#include "Version.h"
+#include "DesktopServices.h"
+
+#include "meta/Index.h"
+#include "meta/VersionList.h"
+
+class IconProxy : public QIdentityProxyModel
+{
+ Q_OBJECT
+ public:
+ IconProxy(QWidget* parentWidget) : QIdentityProxyModel(parentWidget)
+ {
+ connect(parentWidget, &QObject::destroyed, this,
+ &IconProxy::widgetGone);
+ m_parentWidget = parentWidget;
+ }
+
+ virtual QVariant data(const QModelIndex& proxyIndex,
+ int role = Qt::DisplayRole) const override
+ {
+ QVariant var = QIdentityProxyModel::data(proxyIndex, role);
+ int column = proxyIndex.column();
+ if (column == 0 && role == Qt::DecorationRole && m_parentWidget) {
+ if (!var.isNull()) {
+ auto string = var.toString();
+ if (string == "warning") {
+ return APPLICATION->getThemedIcon("status-yellow");
+ } else if (string == "error") {
+ return APPLICATION->getThemedIcon("status-bad");
+ }
+ }
+ return APPLICATION->getThemedIcon("status-good");
+ }
+ return var;
+ }
+ private slots:
+ void widgetGone()
+ {
+ m_parentWidget = nullptr;
+ }
+
+ private:
+ QWidget* m_parentWidget = nullptr;
+};
+
+QIcon VersionPage::icon() const
+{
+ return APPLICATION->icons()->getIcon(m_inst->iconKey());
+}
+bool VersionPage::shouldDisplay() const
+{
+ return true;
+}
+
+QMenu* VersionPage::createPopupMenu()
+{
+ QMenu* filteredMenu = QMainWindow::createPopupMenu();
+ filteredMenu->removeAction(ui->toolBar->toggleViewAction());
+ return filteredMenu;
+}
+
+VersionPage::VersionPage(MinecraftInstance* inst, QWidget* parent)
+ : QMainWindow(parent), ui(new Ui::VersionPage), m_inst(inst)
+{
+ ui->setupUi(this);
+
+ ui->toolBar->insertSpacer(ui->actionReload);
+
+ m_profile = m_inst->getPackProfile();
+
+ reloadPackProfile();
+
+ auto proxy = new IconProxy(ui->packageView);
+ proxy->setSourceModel(m_profile.get());
+
+ m_filterModel = new QSortFilterProxyModel();
+ m_filterModel->setDynamicSortFilter(true);
+ m_filterModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
+ m_filterModel->setSortCaseSensitivity(Qt::CaseInsensitive);
+ m_filterModel->setSourceModel(proxy);
+ m_filterModel->setFilterKeyColumn(-1);
+
+ ui->packageView->setModel(m_filterModel);
+ ui->packageView->installEventFilter(this);
+ ui->packageView->setSelectionMode(QAbstractItemView::SingleSelection);
+ ui->packageView->setContextMenuPolicy(Qt::CustomContextMenu);
+
+ connect(ui->packageView->selectionModel(),
+ &QItemSelectionModel::currentChanged, this,
+ &VersionPage::versionCurrent);
+ auto smodel = ui->packageView->selectionModel();
+ connect(smodel, &QItemSelectionModel::currentChanged, this,
+ &VersionPage::packageCurrent);
+
+ connect(m_profile.get(), &PackProfile::minecraftChanged, this,
+ &VersionPage::updateVersionControls);
+ controlsEnabled = !m_inst->isRunning();
+ updateVersionControls();
+ preselect(0);
+ connect(m_inst, &BaseInstance::runningStatusChanged, this,
+ &VersionPage::updateRunningStatus);
+ connect(ui->packageView, &ModListView::customContextMenuRequested, this,
+ &VersionPage::showContextMenu);
+ connect(ui->filterEdit, &QLineEdit::textChanged, this,
+ &VersionPage::onFilterTextChanged);
+}
+
+VersionPage::~VersionPage()
+{
+ delete ui;
+}
+
+void VersionPage::showContextMenu(const QPoint& pos)
+{
+ auto menu = ui->toolBar->createContextMenu(this, tr("Context menu"));
+ menu->exec(ui->packageView->mapToGlobal(pos));
+ delete menu;
+}
+
+void VersionPage::packageCurrent(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ if (!current.isValid()) {
+ ui->frame->clear();
+ return;
+ }
+ int row = current.row();
+ auto patch = m_profile->getComponent(row);
+ auto severity = patch->getProblemSeverity();
+ switch (severity) {
+ case ProblemSeverity::Warning:
+ ui->frame->setModText(
+ tr("%1 possibly has issues.").arg(patch->getName()));
+ break;
+ case ProblemSeverity::Error:
+ ui->frame->setModText(tr("%1 has issues!").arg(patch->getName()));
+ break;
+ default:
+ case ProblemSeverity::None:
+ ui->frame->clear();
+ return;
+ }
+
+ auto& problems = patch->getProblems();
+ QString problemOut;
+ for (auto& problem : problems) {
+ if (problem.m_severity == ProblemSeverity::Error) {
+ problemOut += tr("Error: ");
+ } else if (problem.m_severity == ProblemSeverity::Warning) {
+ problemOut += tr("Warning: ");
+ }
+ problemOut += problem.m_description;
+ problemOut += "\n";
+ }
+ ui->frame->setModDescription(problemOut);
+}
+
+void VersionPage::updateRunningStatus(bool running)
+{
+ if (controlsEnabled == running) {
+ controlsEnabled = !running;
+ updateVersionControls();
+ }
+}
+
+void VersionPage::updateVersionControls()
+{
+ // FIXME: this is a dirty hack
+ auto minecraftVersion =
+ Version(m_profile->getComponentVersion("net.minecraft"));
+
+ bool supportsFabric = minecraftVersion >= Version("1.14");
+ ui->actionInstall_Fabric->setEnabled(controlsEnabled && supportsFabric);
+
+ bool supportsNeoForge = minecraftVersion >= Version("1.20.1");
+ ui->actionInstall_NeoForge->setEnabled(controlsEnabled && supportsNeoForge);
+
+ bool supportsQuilt = minecraftVersion >= Version("1.14");
+ ui->actionInstall_Quilt->setEnabled(controlsEnabled && supportsQuilt);
+
+ bool supportsLiteLoader = minecraftVersion <= Version("1.12.2");
+ ui->actionInstall_LiteLoader->setEnabled(controlsEnabled &&
+ supportsLiteLoader);
+
+ updateButtons();
+}
+
+void VersionPage::updateButtons(int row)
+{
+ if (row == -1)
+ row = currentRow();
+ auto patch = m_profile->getComponent(row);
+ ui->actionRemove->setEnabled(controlsEnabled && patch &&
+ patch->isRemovable());
+ ui->actionMove_down->setEnabled(controlsEnabled && patch &&
+ patch->isMoveable());
+ ui->actionMove_up->setEnabled(controlsEnabled && patch &&
+ patch->isMoveable());
+ ui->actionChange_version->setEnabled(controlsEnabled && patch &&
+ patch->isVersionChangeable());
+ ui->actionEdit->setEnabled(controlsEnabled && patch && patch->isCustom());
+ ui->actionCustomize->setEnabled(controlsEnabled && patch &&
+ patch->isCustomizable());
+ ui->actionRevert->setEnabled(controlsEnabled && patch &&
+ patch->isRevertible());
+ ui->actionDownload_All->setEnabled(controlsEnabled);
+ ui->actionAdd_Empty->setEnabled(controlsEnabled);
+ ui->actionReload->setEnabled(controlsEnabled);
+ ui->actionInstall_mods->setEnabled(controlsEnabled);
+ ui->actionReplace_Minecraft_jar->setEnabled(controlsEnabled);
+ ui->actionAdd_to_Minecraft_jar->setEnabled(controlsEnabled);
+}
+
+bool VersionPage::reloadPackProfile()
+{
+ try {
+ m_profile->reload(Net::Mode::Online);
+ return true;
+ } catch (const Exception& e) {
+ QMessageBox::critical(this, tr("Error"), e.cause());
+ return false;
+ } catch (...) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Couldn't load the instance profile."));
+ return false;
+ }
+}
+
+void VersionPage::on_actionReload_triggered()
+{
+ reloadPackProfile();
+ m_container->refreshContainer();
+}
+
+void VersionPage::on_actionRemove_triggered()
+{
+ if (ui->packageView->currentIndex().isValid()) {
+ // FIXME: use actual model, not reloading.
+ if (!m_profile->remove(ui->packageView->currentIndex().row())) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Couldn't remove file"));
+ }
+ }
+ updateButtons();
+ reloadPackProfile();
+ m_container->refreshContainer();
+}
+
+void VersionPage::on_actionInstall_mods_triggered()
+{
+ if (m_container) {
+ m_container->selectPage("mods");
+ }
+}
+
+void VersionPage::on_actionAdd_to_Minecraft_jar_triggered()
+{
+ auto list = GuiUtil::BrowseForFiles(
+ "jarmod", tr("Select jar mods"), tr("Minecraft.jar mods (*.zip *.jar)"),
+ APPLICATION->settings()->get("CentralModsDir").toString(),
+ this->parentWidget());
+ if (!list.empty()) {
+ m_profile->installJarMods(list);
+ }
+ updateButtons();
+}
+
+void VersionPage::on_actionReplace_Minecraft_jar_triggered()
+{
+ auto jarPath = GuiUtil::BrowseForFile(
+ "jar", tr("Select jar"), tr("Minecraft.jar replacement (*.jar)"),
+ APPLICATION->settings()->get("CentralModsDir").toString(),
+ this->parentWidget());
+ if (!jarPath.isEmpty()) {
+ m_profile->installCustomJar(jarPath);
+ }
+ updateButtons();
+}
+
+void VersionPage::on_actionMove_up_triggered()
+{
+ try {
+ m_profile->move(currentRow(), PackProfile::MoveUp);
+ } catch (const Exception& e) {
+ QMessageBox::critical(this, tr("Error"), e.cause());
+ }
+ updateButtons();
+}
+
+void VersionPage::on_actionMove_down_triggered()
+{
+ try {
+ m_profile->move(currentRow(), PackProfile::MoveDown);
+ } catch (const Exception& e) {
+ QMessageBox::critical(this, tr("Error"), e.cause());
+ }
+ updateButtons();
+}
+
+void VersionPage::on_actionChange_version_triggered()
+{
+ auto versionRow = currentRow();
+ if (versionRow == -1) {
+ return;
+ }
+ auto patch = m_profile->getComponent(versionRow);
+ auto name = patch->getName();
+ auto list = patch->getVersionList();
+ if (!list) {
+ return;
+ }
+ auto uid = list->uid();
+ // FIXME: this is a horrible HACK. Get version filtering information from
+ // the actual metadata...
+ if (uid == "net.minecraftforge") {
+ on_actionInstall_Forge_triggered();
+ return;
+ } else if (uid == "net.neoforged") {
+ on_actionInstall_NeoForge_triggered();
+ return;
+ } else if (uid == "com.mumfrey.liteloader") {
+ on_actionInstall_LiteLoader_triggered();
+ return;
+ }
+ VersionSelectDialog vselect(list.get(), tr("Change %1 version").arg(name),
+ this);
+ if (uid == "net.fabricmc.intermediary") {
+ vselect.setEmptyString(
+ tr("No intermediary mappings versions are currently available."));
+ vselect.setEmptyErrorString(tr("Couldn't load or download the "
+ "intermediary mappings version lists!"));
+ vselect.setExactFilter(BaseVersionList::ParentVersionRole,
+ m_profile->getComponentVersion("net.minecraft"));
+ }
+ auto currentVersion = patch->getVersion();
+ if (!currentVersion.isEmpty()) {
+ vselect.setCurrentVersion(currentVersion);
+ }
+ if (!vselect.exec() || !vselect.selectedVersion())
+ return;
+
+ qDebug() << "Change" << uid << "to"
+ << vselect.selectedVersion()->descriptor();
+ bool important = false;
+ if (uid == "net.minecraft") {
+ important = true;
+ }
+ m_profile->setComponentVersion(uid, vselect.selectedVersion()->descriptor(),
+ important);
+ m_profile->resolve(Net::Mode::Online);
+ m_container->refreshContainer();
+}
+
+void VersionPage::on_actionDownload_All_triggered()
+{
+ if (!APPLICATION->accounts()->anyAccountIsValid()) {
+ CustomMessageBox::selectable(
+ this, tr("Error"),
+ tr("MeshMC 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;
+ }
+
+ auto updateTask = m_inst->createUpdateTask(Net::Mode::Online);
+ if (!updateTask) {
+ return;
+ }
+ ProgressDialog tDialog(this);
+ connect(updateTask.get(), SIGNAL(failed(QString)),
+ SLOT(onGameUpdateError(QString)));
+ // FIXME: unused return value
+ tDialog.execWithTask(updateTask.get());
+ updateButtons();
+ m_container->refreshContainer();
+}
+
+void VersionPage::on_actionInstall_Forge_triggered()
+{
+ // Forge conflicts with Fabric, NeoForge, and Quilt
+ if (!m_profile->getComponentVersion("net.fabricmc.fabric-loader")
+ .isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Forge is incompatible with Fabric Loader. "
+ "Please remove Fabric Loader first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("net.neoforged").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Forge is incompatible with NeoForge. Please "
+ "remove NeoForge first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("org.quiltmc.quilt-loader").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Forge is incompatible with Quilt Loader. "
+ "Please remove Quilt Loader first."));
+ return;
+ }
+
+ auto vlist = APPLICATION->metadataIndex()->get("net.minecraftforge");
+ if (!vlist) {
+ return;
+ }
+ VersionSelectDialog vselect(vlist.get(), tr("Select Forge version"), this);
+ vselect.setExactFilter(BaseVersionList::ParentVersionRole,
+ m_profile->getComponentVersion("net.minecraft"));
+ vselect.setEmptyString(
+ tr("No Forge versions are currently available for Minecraft ") +
+ m_profile->getComponentVersion("net.minecraft"));
+ vselect.setEmptyErrorString(
+ tr("Couldn't load or download the Forge version lists!"));
+
+ auto currentVersion = m_profile->getComponentVersion("net.minecraftforge");
+ if (!currentVersion.isEmpty()) {
+ vselect.setCurrentVersion(currentVersion);
+ }
+
+ if (vselect.exec() && vselect.selectedVersion()) {
+ auto vsn = vselect.selectedVersion();
+ m_profile->setComponentVersion("net.minecraftforge", vsn->descriptor());
+ m_profile->resolve(Net::Mode::Online);
+ // m_profile->installVersion();
+ preselect(m_profile->rowCount(QModelIndex()) - 1);
+ m_container->refreshContainer();
+ }
+}
+
+void VersionPage::on_actionInstall_Fabric_triggered()
+{
+ // Fabric conflicts with Forge, NeoForge, and Quilt
+ if (!m_profile->getComponentVersion("net.minecraftforge").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Fabric Loader is incompatible with Forge. "
+ "Please remove Forge first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("net.neoforged").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Fabric Loader is incompatible with NeoForge. "
+ "Please remove NeoForge first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("org.quiltmc.quilt-loader").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Fabric Loader is incompatible with Quilt "
+ "Loader. Please remove Quilt Loader first."));
+ return;
+ }
+
+ auto vlist =
+ APPLICATION->metadataIndex()->get("net.fabricmc.fabric-loader");
+ if (!vlist) {
+ return;
+ }
+ VersionSelectDialog vselect(vlist.get(), tr("Select Fabric Loader version"),
+ this);
+ vselect.setEmptyString(
+ tr("No Fabric Loader versions are currently available."));
+ vselect.setEmptyErrorString(
+ tr("Couldn't load or download the Fabric Loader version lists!"));
+
+ auto currentVersion =
+ m_profile->getComponentVersion("net.fabricmc.fabric-loader");
+ if (!currentVersion.isEmpty()) {
+ vselect.setCurrentVersion(currentVersion);
+ }
+
+ if (vselect.exec() && vselect.selectedVersion()) {
+ auto vsn = vselect.selectedVersion();
+ m_profile->setComponentVersion("net.fabricmc.fabric-loader",
+ vsn->descriptor());
+ m_profile->resolve(Net::Mode::Online);
+ preselect(m_profile->rowCount(QModelIndex()) - 1);
+ m_container->refreshContainer();
+ }
+}
+
+void VersionPage::on_actionInstall_NeoForge_triggered()
+{
+ // NeoForge conflicts with Forge, Fabric, and Quilt
+ if (!m_profile->getComponentVersion("net.minecraftforge").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("NeoForge is incompatible with Forge. Please "
+ "remove Forge first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("net.fabricmc.fabric-loader")
+ .isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("NeoForge is incompatible with Fabric Loader. "
+ "Please remove Fabric Loader first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("org.quiltmc.quilt-loader").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("NeoForge is incompatible with Quilt Loader. "
+ "Please remove Quilt Loader first."));
+ return;
+ }
+
+ auto vlist = APPLICATION->metadataIndex()->get("net.neoforged");
+ if (!vlist) {
+ return;
+ }
+ VersionSelectDialog vselect(vlist.get(), tr("Select NeoForge version"),
+ this);
+ vselect.setExactFilter(BaseVersionList::ParentVersionRole,
+ m_profile->getComponentVersion("net.minecraft"));
+ vselect.setEmptyString(
+ tr("No NeoForge versions are currently available for Minecraft ") +
+ m_profile->getComponentVersion("net.minecraft"));
+ vselect.setEmptyErrorString(
+ tr("Couldn't load or download the NeoForge version lists!"));
+
+ auto currentVersion = m_profile->getComponentVersion("net.neoforged");
+ if (!currentVersion.isEmpty()) {
+ vselect.setCurrentVersion(currentVersion);
+ }
+
+ if (vselect.exec() && vselect.selectedVersion()) {
+ auto vsn = vselect.selectedVersion();
+ m_profile->setComponentVersion("net.neoforged", vsn->descriptor());
+ m_profile->resolve(Net::Mode::Online);
+ preselect(m_profile->rowCount(QModelIndex()) - 1);
+ m_container->refreshContainer();
+ }
+}
+
+void VersionPage::on_actionInstall_Quilt_triggered()
+{
+ // Quilt conflicts with Forge, Fabric, and NeoForge
+ if (!m_profile->getComponentVersion("net.minecraftforge").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Quilt Loader is incompatible with Forge. "
+ "Please remove Forge first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("net.fabricmc.fabric-loader")
+ .isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Quilt Loader is incompatible with Fabric "
+ "Loader. Please remove Fabric Loader first."));
+ return;
+ }
+ if (!m_profile->getComponentVersion("net.neoforged").isEmpty()) {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Quilt Loader is incompatible with NeoForge. "
+ "Please remove NeoForge first."));
+ return;
+ }
+
+ auto vlist = APPLICATION->metadataIndex()->get("org.quiltmc.quilt-loader");
+ if (!vlist) {
+ return;
+ }
+ VersionSelectDialog vselect(vlist.get(), tr("Select Quilt Loader version"),
+ this);
+ vselect.setEmptyString(
+ tr("No Quilt Loader versions are currently available."));
+ vselect.setEmptyErrorString(
+ tr("Couldn't load or download the Quilt Loader version lists!"));
+
+ auto currentVersion =
+ m_profile->getComponentVersion("org.quiltmc.quilt-loader");
+ if (!currentVersion.isEmpty()) {
+ vselect.setCurrentVersion(currentVersion);
+ }
+
+ if (vselect.exec() && vselect.selectedVersion()) {
+ auto vsn = vselect.selectedVersion();
+ m_profile->setComponentVersion("org.quiltmc.quilt-loader",
+ vsn->descriptor());
+ m_profile->resolve(Net::Mode::Online);
+ preselect(m_profile->rowCount(QModelIndex()) - 1);
+ m_container->refreshContainer();
+ }
+}
+
+void VersionPage::on_actionAdd_Empty_triggered()
+{
+ NewComponentDialog compdialog(QString(), QString(), this);
+ QStringList blacklist;
+ for (int i = 0; i < m_profile->rowCount(); i++) {
+ auto comp = m_profile->getComponent(i);
+ blacklist.push_back(comp->getID());
+ }
+ compdialog.setBlacklist(blacklist);
+ if (compdialog.exec()) {
+ qDebug() << "name:" << compdialog.name();
+ qDebug() << "uid:" << compdialog.uid();
+ m_profile->installEmpty(compdialog.uid(), compdialog.name());
+ }
+}
+
+void VersionPage::on_actionInstall_LiteLoader_triggered()
+{
+ auto vlist = APPLICATION->metadataIndex()->get("com.mumfrey.liteloader");
+ if (!vlist) {
+ return;
+ }
+ VersionSelectDialog vselect(vlist.get(), tr("Select LiteLoader version"),
+ this);
+ vselect.setExactFilter(BaseVersionList::ParentVersionRole,
+ m_profile->getComponentVersion("net.minecraft"));
+ vselect.setEmptyString(
+ tr("No LiteLoader versions are currently available for Minecraft ") +
+ m_profile->getComponentVersion("net.minecraft"));
+ vselect.setEmptyErrorString(
+ tr("Couldn't load or download the LiteLoader version lists!"));
+
+ auto currentVersion =
+ m_profile->getComponentVersion("com.mumfrey.liteloader");
+ if (!currentVersion.isEmpty()) {
+ vselect.setCurrentVersion(currentVersion);
+ }
+
+ if (vselect.exec() && vselect.selectedVersion()) {
+ auto vsn = vselect.selectedVersion();
+ m_profile->setComponentVersion("com.mumfrey.liteloader",
+ vsn->descriptor());
+ m_profile->resolve(Net::Mode::Online);
+ // m_profile->installVersion(vselect.selectedVersion());
+ preselect(m_profile->rowCount(QModelIndex()) - 1);
+ m_container->refreshContainer();
+ }
+}
+
+void VersionPage::on_actionLibrariesFolder_triggered()
+{
+ DesktopServices::openDirectory(m_inst->getLocalLibraryPath(), true);
+}
+
+void VersionPage::on_actionMinecraftFolder_triggered()
+{
+ DesktopServices::openDirectory(m_inst->gameRoot(), true);
+}
+
+void VersionPage::versionCurrent(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ currentIdx = current.row();
+ updateButtons(currentIdx);
+}
+
+void VersionPage::preselect(int row)
+{
+ if (row < 0) {
+ row = 0;
+ }
+ if (row >= m_profile->rowCount(QModelIndex())) {
+ row = m_profile->rowCount(QModelIndex()) - 1;
+ }
+ if (row < 0) {
+ return;
+ }
+ auto model_index = m_profile->index(row);
+ ui->packageView->selectionModel()->select(
+ model_index,
+ QItemSelectionModel::SelectCurrent | QItemSelectionModel::Rows);
+ updateButtons(row);
+}
+
+void VersionPage::onGameUpdateError(QString error)
+{
+ CustomMessageBox::selectable(this, tr("Error updating instance"), error,
+ QMessageBox::Warning)
+ ->show();
+}
+
+Component* VersionPage::current()
+{
+ auto row = currentRow();
+ if (row < 0) {
+ return nullptr;
+ }
+ return m_profile->getComponent(row);
+}
+
+int VersionPage::currentRow()
+{
+ if (ui->packageView->selectionModel()->selectedRows().isEmpty()) {
+ return -1;
+ }
+ return ui->packageView->selectionModel()->selectedRows().first().row();
+}
+
+void VersionPage::on_actionCustomize_triggered()
+{
+ auto version = currentRow();
+ if (version == -1) {
+ return;
+ }
+ auto patch = m_profile->getComponent(version);
+ if (!patch->getVersionFile()) {
+ // TODO: wait for the update task to finish here...
+ return;
+ }
+ if (!m_profile->customize(version)) {
+ // TODO: some error box here
+ }
+ updateButtons();
+ preselect(currentIdx);
+}
+
+void VersionPage::on_actionEdit_triggered()
+{
+ auto version = current();
+ if (!version) {
+ return;
+ }
+ auto filename = version->getFilename();
+ if (!QFileInfo::exists(filename)) {
+ qWarning() << "file" << filename
+ << "can't be opened for editing, doesn't exist!";
+ return;
+ }
+ APPLICATION->openJsonEditor(filename);
+}
+
+void VersionPage::on_actionRevert_triggered()
+{
+ auto version = currentRow();
+ if (version == -1) {
+ return;
+ }
+ if (!m_profile->revertToBase(version)) {
+ // TODO: some error box here
+ }
+ updateButtons();
+ preselect(currentIdx);
+ m_container->refreshContainer();
+}
+
+void VersionPage::onFilterTextChanged(const QString& newContents)
+{
+ m_filterModel->setFilterFixedString(newContents);
+}
+
+#include "VersionPage.moc"
diff --git a/meshmc/launcher/ui/pages/instance/VersionPage.h b/meshmc/launcher/ui/pages/instance/VersionPage.h
new file mode 100644
index 0000000000..989a4b735b
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/VersionPage.h
@@ -0,0 +1,131 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QMainWindow>
+
+#include "minecraft/MinecraftInstance.h"
+#include "minecraft/PackProfile.h"
+#include "ui/pages/BasePage.h"
+
+namespace Ui
+{
+ class VersionPage;
+}
+
+class VersionPage : public QMainWindow, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit VersionPage(MinecraftInstance* inst, QWidget* parent = 0);
+ virtual ~VersionPage();
+ virtual QString displayName() const override
+ {
+ return tr("Version");
+ }
+ virtual QIcon icon() const override;
+ virtual QString id() const override
+ {
+ return "version";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Instance-Version";
+ }
+ virtual bool shouldDisplay() const override;
+
+ private slots:
+ void on_actionChange_version_triggered();
+ void on_actionInstall_Forge_triggered();
+ void on_actionInstall_Fabric_triggered();
+ void on_actionInstall_NeoForge_triggered();
+ void on_actionInstall_Quilt_triggered();
+ void on_actionAdd_Empty_triggered();
+ void on_actionInstall_LiteLoader_triggered();
+ void on_actionReload_triggered();
+ void on_actionRemove_triggered();
+ void on_actionMove_up_triggered();
+ void on_actionMove_down_triggered();
+ void on_actionAdd_to_Minecraft_jar_triggered();
+ void on_actionReplace_Minecraft_jar_triggered();
+ void on_actionRevert_triggered();
+ void on_actionEdit_triggered();
+ void on_actionInstall_mods_triggered();
+ void on_actionCustomize_triggered();
+ void on_actionDownload_All_triggered();
+
+ void on_actionMinecraftFolder_triggered();
+ void on_actionLibrariesFolder_triggered();
+
+ void updateVersionControls();
+
+ private:
+ Component* current();
+ int currentRow();
+ void updateButtons(int row = -1);
+ void preselect(int row = 0);
+ int doUpdate();
+
+ protected:
+ QMenu* createPopupMenu() override;
+
+ /// FIXME: this shouldn't be necessary!
+ bool reloadPackProfile();
+
+ private:
+ Ui::VersionPage* ui;
+ QSortFilterProxyModel* m_filterModel;
+ std::shared_ptr<PackProfile> m_profile;
+ MinecraftInstance* m_inst;
+ int currentIdx = 0;
+ bool controlsEnabled = false;
+
+ public slots:
+ void versionCurrent(const QModelIndex& current,
+ const QModelIndex& previous);
+
+ private slots:
+ void updateRunningStatus(bool running);
+ void onGameUpdateError(QString error);
+ void packageCurrent(const QModelIndex& current,
+ const QModelIndex& previous);
+ void showContextMenu(const QPoint& pos);
+ void onFilterTextChanged(const QString& newContents);
+};
diff --git a/meshmc/launcher/ui/pages/instance/VersionPage.ui b/meshmc/launcher/ui/pages/instance/VersionPage.ui
new file mode 100644
index 0000000000..d6d0a74570
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/VersionPage.ui
@@ -0,0 +1,303 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>VersionPage</class>
+ <widget class="QMainWindow" name="VersionPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>961</width>
+ <height>1091</height>
+ </rect>
+ </property>
+ <widget class="QWidget" name="centralwidget">
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="ModListView" name="packageView">
+ <property name="verticalScrollBarPolicy">
+ <enum>Qt::ScrollBarAlwaysOn</enum>
+ </property>
+ <property name="horizontalScrollBarPolicy">
+ <enum>Qt::ScrollBarAlwaysOff</enum>
+ </property>
+ <property name="sortingEnabled">
+ <bool>false</bool>
+ </property>
+ <property name="headerHidden">
+ <bool>false</bool>
+ </property>
+ <attribute name="headerVisible">
+ <bool>true</bool>
+ </attribute>
+ </widget>
+ </item>
+ <item>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="1">
+ <widget class="QLineEdit" name="filterEdit">
+ <property name="clearButtonEnabled">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0">
+ <widget class="QLabel" name="filterLabel">
+ <property name="text">
+ <string>Filter:</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="MCModInfoFrame" name="frame">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Minimum">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WideBar" name="toolBar">
+ <property name="windowTitle">
+ <string>Actions</string>
+ </property>
+ <property name="allowedAreas">
+ <set>Qt::LeftToolBarArea|Qt::RightToolBarArea</set>
+ </property>
+ <property name="toolButtonStyle">
+ <enum>Qt::ToolButtonTextOnly</enum>
+ </property>
+ <property name="floatable">
+ <bool>false</bool>
+ </property>
+ <attribute name="toolBarArea">
+ <enum>RightToolBarArea</enum>
+ </attribute>
+ <attribute name="toolBarBreak">
+ <bool>false</bool>
+ </attribute>
+ <addaction name="actionChange_version"/>
+ <addaction name="actionMove_up"/>
+ <addaction name="actionMove_down"/>
+ <addaction name="actionRemove"/>
+ <addaction name="separator"/>
+ <addaction name="actionCustomize"/>
+ <addaction name="actionEdit"/>
+ <addaction name="actionRevert"/>
+ <addaction name="separator"/>
+ <addaction name="actionInstall_Forge"/>
+ <addaction name="actionInstall_Fabric"/>
+ <addaction name="actionInstall_NeoForge"/>
+ <addaction name="actionInstall_Quilt"/>
+ <addaction name="actionInstall_LiteLoader"/>
+ <addaction name="actionInstall_mods"/>
+ <addaction name="separator"/>
+ <addaction name="actionAdd_to_Minecraft_jar"/>
+ <addaction name="actionReplace_Minecraft_jar"/>
+ <addaction name="actionAdd_Empty"/>
+ <addaction name="separator"/>
+ <addaction name="actionMinecraftFolder"/>
+ <addaction name="actionLibrariesFolder"/>
+ <addaction name="separator"/>
+ <addaction name="actionReload"/>
+ <addaction name="actionDownload_All"/>
+ </widget>
+ <action name="actionChange_version">
+ <property name="text">
+ <string>Change version</string>
+ </property>
+ <property name="toolTip">
+ <string>Change version of the selected package.</string>
+ </property>
+ </action>
+ <action name="actionMove_up">
+ <property name="text">
+ <string>Move up</string>
+ </property>
+ <property name="toolTip">
+ <string>Make the selected package apply sooner.</string>
+ </property>
+ </action>
+ <action name="actionMove_down">
+ <property name="text">
+ <string>Move down</string>
+ </property>
+ <property name="toolTip">
+ <string>Make the selected package apply later.</string>
+ </property>
+ </action>
+ <action name="actionRemove">
+ <property name="text">
+ <string>Remove</string>
+ </property>
+ <property name="toolTip">
+ <string>Remove selected package from the instance.</string>
+ </property>
+ </action>
+ <action name="actionCustomize">
+ <property name="text">
+ <string>Customize</string>
+ </property>
+ <property name="toolTip">
+ <string>Customize selected package.</string>
+ </property>
+ </action>
+ <action name="actionEdit">
+ <property name="text">
+ <string>Edit</string>
+ </property>
+ <property name="toolTip">
+ <string>Edit selected package.</string>
+ </property>
+ </action>
+ <action name="actionRevert">
+ <property name="text">
+ <string>Revert</string>
+ </property>
+ <property name="toolTip">
+ <string>Revert the selected package to default.</string>
+ </property>
+ </action>
+ <action name="actionInstall_Forge">
+ <property name="text">
+ <string>Install Forge</string>
+ </property>
+ <property name="toolTip">
+ <string>Install the Minecraft Forge package.</string>
+ </property>
+ </action>
+ <action name="actionInstall_Fabric">
+ <property name="text">
+ <string>Install Fabric</string>
+ </property>
+ <property name="toolTip">
+ <string>Install the Fabric Loader package.</string>
+ </property>
+ </action>
+ <action name="actionInstall_LiteLoader">
+ <property name="text">
+ <string>Install LiteLoader</string>
+ </property>
+ <property name="toolTip">
+ <string>Install the LiteLoader package.</string>
+ </property>
+ </action>
+ <action name="actionInstall_NeoForge">
+ <property name="text">
+ <string>Install NeoForge</string>
+ </property>
+ <property name="toolTip">
+ <string>Install the NeoForge mod loader.</string>
+ </property>
+ </action>
+ <action name="actionInstall_Quilt">
+ <property name="text">
+ <string>Install Quilt</string>
+ </property>
+ <property name="toolTip">
+ <string>Install the Quilt Loader package.</string>
+ </property>
+ </action>
+ <action name="actionInstall_mods">
+ <property name="text">
+ <string>Install mods</string>
+ </property>
+ <property name="toolTip">
+ <string>Install normal mods.</string>
+ </property>
+ </action>
+ <action name="actionAdd_to_Minecraft_jar">
+ <property name="text">
+ <string>Add to Minecraft.jar</string>
+ </property>
+ <property name="toolTip">
+ <string>Add a mod into the Minecraft jar file.</string>
+ </property>
+ </action>
+ <action name="actionReplace_Minecraft_jar">
+ <property name="text">
+ <string>Replace Minecraft.jar</string>
+ </property>
+ </action>
+ <action name="actionAdd_Empty">
+ <property name="text">
+ <string>Add Empty</string>
+ </property>
+ <property name="toolTip">
+ <string>Add an empty custom package.</string>
+ </property>
+ </action>
+ <action name="actionReload">
+ <property name="text">
+ <string>Reload</string>
+ </property>
+ <property name="toolTip">
+ <string>Reload all packages.</string>
+ </property>
+ </action>
+ <action name="actionDownload_All">
+ <property name="text">
+ <string>Download All</string>
+ </property>
+ <property name="toolTip">
+ <string>Download the files needed to launch the instance now.</string>
+ </property>
+ </action>
+ <action name="actionMinecraftFolder">
+ <property name="text">
+ <string>Open .minecraft</string>
+ </property>
+ <property name="toolTip">
+ <string>Open the instance's .minecraft folder.</string>
+ </property>
+ </action>
+ <action name="actionLibrariesFolder">
+ <property name="text">
+ <string>Open libraries</string>
+ </property>
+ <property name="toolTip">
+ <string>Open the instance's local libraries folder.</string>
+ </property>
+ </action>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>ModListView</class>
+ <extends>QTreeView</extends>
+ <header>ui/widgets/ModListView.h</header>
+ </customwidget>
+ <customwidget>
+ <class>MCModInfoFrame</class>
+ <extends>QFrame</extends>
+ <header>ui/widgets/MCModInfoFrame.h</header>
+ <container>1</container>
+ </customwidget>
+ <customwidget>
+ <class>WideBar</class>
+ <extends>QToolBar</extends>
+ <header>ui/widgets/WideBar.h</header>
+ </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/instance/WorldListPage.cpp b/meshmc/launcher/ui/pages/instance/WorldListPage.cpp
new file mode 100644
index 0000000000..3759b77823
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/WorldListPage.cpp
@@ -0,0 +1,422 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2015-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 "WorldListPage.h"
+#include "ui_WorldListPage.h"
+#include "minecraft/WorldList.h"
+
+#include <QEvent>
+#include <QMenu>
+#include <QKeyEvent>
+#include <QClipboard>
+#include <QMessageBox>
+#include <QTreeView>
+#include <QInputDialog>
+#include <QProcess>
+
+#include "tools/MCEditTool.h"
+#include "FileSystem.h"
+
+#include "ui/GuiUtil.h"
+#include "DesktopServices.h"
+
+#include "Application.h"
+
+class WorldListProxyModel : public QSortFilterProxyModel
+{
+ Q_OBJECT
+
+ public:
+ WorldListProxyModel(QObject* parent) : QSortFilterProxyModel(parent) {}
+
+ virtual QVariant data(const QModelIndex& index,
+ int role = Qt::DisplayRole) const
+ {
+ QModelIndex sourceIndex = mapToSource(index);
+
+ if (index.column() == 0 && role == Qt::DecorationRole) {
+ WorldList* worlds = qobject_cast<WorldList*>(sourceModel());
+ auto iconFile =
+ worlds->data(sourceIndex, WorldList::IconFileRole).toString();
+ if (iconFile.isNull()) {
+ // NOTE: Minecraft uses the same placeholder for servers AND
+ // worlds
+ return APPLICATION->getThemedIcon("unknown_server");
+ }
+ return QIcon(iconFile);
+ }
+
+ return sourceIndex.data(role);
+ }
+};
+
+WorldListPage::WorldListPage(BaseInstance* inst,
+ std::shared_ptr<WorldList> worlds, QWidget* parent)
+ : QMainWindow(parent), m_inst(inst), ui(new Ui::WorldListPage),
+ m_worlds(worlds)
+{
+ ui->setupUi(this);
+
+ ui->toolBar->insertSpacer(ui->actionRefresh);
+
+ WorldListProxyModel* proxy = new WorldListProxyModel(this);
+ proxy->setSortCaseSensitivity(Qt::CaseInsensitive);
+ proxy->setSourceModel(m_worlds.get());
+ ui->worldTreeView->setSortingEnabled(true);
+ ui->worldTreeView->setModel(proxy);
+ ui->worldTreeView->installEventFilter(this);
+ ui->worldTreeView->setContextMenuPolicy(Qt::CustomContextMenu);
+ ui->worldTreeView->setIconSize(QSize(64, 64));
+ connect(ui->worldTreeView, &QTreeView::customContextMenuRequested, this,
+ &WorldListPage::ShowContextMenu);
+
+ auto head = ui->worldTreeView->header();
+ head->setSectionResizeMode(0, QHeaderView::Stretch);
+ head->setSectionResizeMode(1, QHeaderView::ResizeToContents);
+
+ connect(ui->worldTreeView->selectionModel(),
+ &QItemSelectionModel::currentChanged, this,
+ &WorldListPage::worldChanged);
+ worldChanged(QModelIndex(), QModelIndex());
+}
+
+void WorldListPage::openedImpl()
+{
+ m_worlds->startWatching();
+}
+
+void WorldListPage::closedImpl()
+{
+ m_worlds->stopWatching();
+}
+
+WorldListPage::~WorldListPage()
+{
+ m_worlds->stopWatching();
+ delete ui;
+}
+
+void WorldListPage::ShowContextMenu(const QPoint& pos)
+{
+ auto menu = ui->toolBar->createContextMenu(this, tr("Context menu"));
+ menu->exec(ui->worldTreeView->mapToGlobal(pos));
+ delete menu;
+}
+
+QMenu* WorldListPage::createPopupMenu()
+{
+ QMenu* filteredMenu = QMainWindow::createPopupMenu();
+ filteredMenu->removeAction(ui->toolBar->toggleViewAction());
+ return filteredMenu;
+}
+
+bool WorldListPage::shouldDisplay() const
+{
+ return true;
+}
+
+bool WorldListPage::worldListFilter(QKeyEvent* keyEvent)
+{
+ switch (keyEvent->key()) {
+ case Qt::Key_Delete:
+ on_actionRemove_triggered();
+ return true;
+ default:
+ break;
+ }
+ return QWidget::eventFilter(ui->worldTreeView, keyEvent);
+}
+
+bool WorldListPage::eventFilter(QObject* obj, QEvent* ev)
+{
+ if (ev->type() != QEvent::KeyPress) {
+ return QWidget::eventFilter(obj, ev);
+ }
+ QKeyEvent* keyEvent = static_cast<QKeyEvent*>(ev);
+ if (obj == ui->worldTreeView)
+ return worldListFilter(keyEvent);
+ return QWidget::eventFilter(obj, ev);
+}
+
+void WorldListPage::on_actionRemove_triggered()
+{
+ auto proxiedIndex = getSelectedWorld();
+
+ if (!proxiedIndex.isValid())
+ return;
+
+ auto result = QMessageBox::question(
+ this, tr("Are you sure?"),
+ tr("This will remove the selected world permenantly.\n"
+ "The world will be gone forever (A LONG TIME).\n"
+ "\n"
+ "Do you want to continue?"));
+ if (result != QMessageBox::Yes) {
+ return;
+ }
+ m_worlds->stopWatching();
+ m_worlds->deleteWorld(proxiedIndex.row());
+ m_worlds->startWatching();
+}
+
+void WorldListPage::on_actionView_Folder_triggered()
+{
+ DesktopServices::openDirectory(m_worlds->dir().absolutePath(), true);
+}
+
+void WorldListPage::on_actionDatapacks_triggered()
+{
+ QModelIndex index = getSelectedWorld();
+
+ if (!index.isValid()) {
+ return;
+ }
+
+ if (!worldSafetyNagQuestion())
+ return;
+
+ auto fullPath = m_worlds->data(index, WorldList::FolderRole).toString();
+
+ DesktopServices::openDirectory(FS::PathCombine(fullPath, "datapacks"),
+ true);
+}
+
+void WorldListPage::on_actionReset_Icon_triggered()
+{
+ auto proxiedIndex = getSelectedWorld();
+
+ if (!proxiedIndex.isValid())
+ return;
+
+ if (m_worlds->resetIcon(proxiedIndex.row())) {
+ ui->actionReset_Icon->setEnabled(false);
+ }
+}
+
+QModelIndex WorldListPage::getSelectedWorld()
+{
+ auto index = ui->worldTreeView->selectionModel()->currentIndex();
+
+ auto proxy = (QSortFilterProxyModel*)ui->worldTreeView->model();
+ return proxy->mapToSource(index);
+}
+
+void WorldListPage::on_actionCopy_Seed_triggered()
+{
+ QModelIndex index = getSelectedWorld();
+
+ if (!index.isValid()) {
+ return;
+ }
+ int64_t seed = m_worlds->data(index, WorldList::SeedRole).toLongLong();
+ APPLICATION->clipboard()->setText(QString::number(seed));
+}
+
+void WorldListPage::on_actionMCEdit_triggered()
+{
+ if (m_mceditStarting)
+ return;
+
+ auto mcedit = APPLICATION->mcedit();
+
+ const QString mceditPath = mcedit->path();
+
+ QModelIndex index = getSelectedWorld();
+
+ if (!index.isValid()) {
+ return;
+ }
+
+ if (!worldSafetyNagQuestion())
+ return;
+
+ auto fullPath = m_worlds->data(index, WorldList::FolderRole).toString();
+
+ auto program = mcedit->getProgramPath();
+ if (program.size()) {
+#ifdef Q_OS_WIN32
+ if (!QProcess::startDetached(program, {fullPath}, mceditPath)) {
+ mceditError();
+ }
+#else
+ m_mceditProcess.reset(new LoggedProcess());
+ m_mceditProcess->setDetachable(true);
+ connect(m_mceditProcess.get(), &LoggedProcess::stateChanged, this,
+ &WorldListPage::mceditState);
+ m_mceditProcess->start(program, {fullPath});
+ m_mceditProcess->setWorkingDirectory(mceditPath);
+ m_mceditStarting = true;
+#endif
+ } else {
+ QMessageBox::warning(
+ this->parentWidget(), tr("No MCEdit found or set up!"),
+ tr("You do not have MCEdit set up or it was moved.\nYou can set it "
+ "up in the global settings."));
+ }
+}
+
+void WorldListPage::mceditError()
+{
+ QMessageBox::warning(
+ this->parentWidget(), tr("MCEdit failed to start!"),
+ tr("MCEdit failed to start.\nIt may be necessary to reinstall it."));
+}
+
+void WorldListPage::mceditState(LoggedProcess::State state)
+{
+ bool failed = false;
+ switch (state) {
+ case LoggedProcess::NotRunning:
+ case LoggedProcess::Starting:
+ return;
+ case LoggedProcess::FailedToStart:
+ case LoggedProcess::Crashed:
+ case LoggedProcess::Aborted: {
+ failed = true;
+ }
+ case LoggedProcess::Running:
+ case LoggedProcess::Finished: {
+ m_mceditStarting = false;
+ break;
+ }
+ }
+ if (failed) {
+ mceditError();
+ }
+}
+
+void WorldListPage::worldChanged(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ QModelIndex index = getSelectedWorld();
+ bool enable = index.isValid();
+ ui->actionCopy_Seed->setEnabled(enable);
+ ui->actionMCEdit->setEnabled(enable);
+ ui->actionRemove->setEnabled(enable);
+ ui->actionCopy->setEnabled(enable);
+ ui->actionRename->setEnabled(enable);
+ ui->actionDatapacks->setEnabled(enable);
+ bool hasIcon = !index.data(WorldList::IconFileRole).isNull();
+ ui->actionReset_Icon->setEnabled(enable && hasIcon);
+}
+
+void WorldListPage::on_actionAdd_triggered()
+{
+ auto list = GuiUtil::BrowseForFiles(displayName(),
+ tr("Select a Minecraft world zip"),
+ tr("Minecraft World Zip File (*.zip)"),
+ QString(), this->parentWidget());
+ if (!list.empty()) {
+ m_worlds->stopWatching();
+ for (auto filename : list) {
+ m_worlds->installWorld(QFileInfo(filename));
+ }
+ m_worlds->startWatching();
+ }
+}
+
+bool WorldListPage::isWorldSafe(QModelIndex)
+{
+ return !m_inst->isRunning();
+}
+
+bool WorldListPage::worldSafetyNagQuestion()
+{
+ if (!isWorldSafe(getSelectedWorld())) {
+ auto result = QMessageBox::question(
+ this, tr("Copy World"),
+ tr("Changing a world while Minecraft is running is potentially "
+ "unsafe.\nDo you wish to proceed?"));
+ if (result == QMessageBox::No) {
+ return false;
+ }
+ }
+ return true;
+}
+
+void WorldListPage::on_actionCopy_triggered()
+{
+ QModelIndex index = getSelectedWorld();
+ if (!index.isValid()) {
+ return;
+ }
+
+ if (!worldSafetyNagQuestion())
+ return;
+
+ auto worldVariant = m_worlds->data(index, WorldList::ObjectRole);
+ auto world = (World*)worldVariant.value<void*>();
+ bool ok = false;
+ QString name = QInputDialog::getText(this, tr("World name"),
+ tr("Enter a new name for the copy."),
+ QLineEdit::Normal, world->name(), &ok);
+
+ if (ok && name.length() > 0) {
+ world->install(m_worlds->dir().absolutePath(), name);
+ }
+}
+
+void WorldListPage::on_actionRename_triggered()
+{
+ QModelIndex index = getSelectedWorld();
+ if (!index.isValid()) {
+ return;
+ }
+
+ if (!worldSafetyNagQuestion())
+ return;
+
+ auto worldVariant = m_worlds->data(index, WorldList::ObjectRole);
+ auto world = (World*)worldVariant.value<void*>();
+
+ bool ok = false;
+ QString name = QInputDialog::getText(this, tr("World name"),
+ tr("Enter a new world name."),
+ QLineEdit::Normal, world->name(), &ok);
+
+ if (ok && name.length() > 0) {
+ world->rename(name);
+ }
+}
+
+void WorldListPage::on_actionRefresh_triggered()
+{
+ m_worlds->update();
+}
+
+#include "WorldListPage.moc"
diff --git a/meshmc/launcher/ui/pages/instance/WorldListPage.h b/meshmc/launcher/ui/pages/instance/WorldListPage.h
new file mode 100644
index 0000000000..5716a32f55
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/WorldListPage.h
@@ -0,0 +1,120 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2015-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.
+ */
+
+#pragma once
+
+#include <QMainWindow>
+
+#include "minecraft/MinecraftInstance.h"
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include <LoggedProcess.h>
+
+class WorldList;
+namespace Ui
+{
+ class WorldListPage;
+}
+
+class WorldListPage : public QMainWindow, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit WorldListPage(BaseInstance* inst,
+ std::shared_ptr<WorldList> worlds,
+ QWidget* parent = 0);
+ virtual ~WorldListPage();
+
+ virtual QString displayName() const override
+ {
+ return tr("Worlds");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("worlds");
+ }
+ virtual QString id() const override
+ {
+ return "worlds";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Worlds";
+ }
+ virtual bool shouldDisplay() const override;
+
+ virtual void openedImpl() override;
+ virtual void closedImpl() override;
+
+ protected:
+ bool eventFilter(QObject* obj, QEvent* ev) override;
+ bool worldListFilter(QKeyEvent* ev);
+ QMenu* createPopupMenu() override;
+
+ protected:
+ BaseInstance* m_inst;
+
+ private:
+ QModelIndex getSelectedWorld();
+ bool isWorldSafe(QModelIndex index);
+ bool worldSafetyNagQuestion();
+ void mceditError();
+
+ private:
+ Ui::WorldListPage* ui;
+ std::shared_ptr<WorldList> m_worlds;
+ unique_qobject_ptr<LoggedProcess> m_mceditProcess;
+ bool m_mceditStarting = false;
+
+ private slots:
+ void on_actionCopy_Seed_triggered();
+ void on_actionMCEdit_triggered();
+ void on_actionRemove_triggered();
+ void on_actionAdd_triggered();
+ void on_actionCopy_triggered();
+ void on_actionRename_triggered();
+ void on_actionRefresh_triggered();
+ void on_actionView_Folder_triggered();
+ void on_actionDatapacks_triggered();
+ void on_actionReset_Icon_triggered();
+ void worldChanged(const QModelIndex& current, const QModelIndex& previous);
+ void mceditState(LoggedProcess::State state);
+
+ void ShowContextMenu(const QPoint& pos);
+};
diff --git a/meshmc/launcher/ui/pages/instance/WorldListPage.ui b/meshmc/launcher/ui/pages/instance/WorldListPage.ui
new file mode 100644
index 0000000000..7c68bfaee4
--- /dev/null
+++ b/meshmc/launcher/ui/pages/instance/WorldListPage.ui
@@ -0,0 +1,161 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>WorldListPage</class>
+ <widget class="QMainWindow" name="WorldListPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>800</width>
+ <height>600</height>
+ </rect>
+ </property>
+ <widget class="QWidget" name="centralwidget">
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTreeView" name="worldTreeView">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="acceptDrops">
+ <bool>true</bool>
+ </property>
+ <property name="dragDropMode">
+ <enum>QAbstractItemView::DragDrop</enum>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <property name="rootIsDecorated">
+ <bool>false</bool>
+ </property>
+ <property name="itemsExpandable">
+ <bool>false</bool>
+ </property>
+ <property name="sortingEnabled">
+ <bool>true</bool>
+ </property>
+ <property name="allColumnsShowFocus">
+ <bool>true</bool>
+ </property>
+ <attribute name="headerStretchLastSection">
+ <bool>false</bool>
+ </attribute>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WideBar" name="toolBar">
+ <property name="windowTitle">
+ <string>Actions</string>
+ </property>
+ <property name="allowedAreas">
+ <set>Qt::LeftToolBarArea|Qt::RightToolBarArea</set>
+ </property>
+ <property name="toolButtonStyle">
+ <enum>Qt::ToolButtonTextOnly</enum>
+ </property>
+ <property name="floatable">
+ <bool>false</bool>
+ </property>
+ <attribute name="toolBarArea">
+ <enum>RightToolBarArea</enum>
+ </attribute>
+ <attribute name="toolBarBreak">
+ <bool>false</bool>
+ </attribute>
+ <addaction name="actionAdd"/>
+ <addaction name="separator"/>
+ <addaction name="actionRename"/>
+ <addaction name="actionCopy"/>
+ <addaction name="actionRemove"/>
+ <addaction name="actionMCEdit"/>
+ <addaction name="actionDatapacks"/>
+ <addaction name="actionReset_Icon"/>
+ <addaction name="separator"/>
+ <addaction name="actionCopy_Seed"/>
+ <addaction name="actionRefresh"/>
+ <addaction name="actionView_Folder"/>
+ </widget>
+ <action name="actionAdd">
+ <property name="text">
+ <string>Add</string>
+ </property>
+ </action>
+ <action name="actionRename">
+ <property name="text">
+ <string>Rename</string>
+ </property>
+ </action>
+ <action name="actionCopy">
+ <property name="text">
+ <string>Copy</string>
+ </property>
+ </action>
+ <action name="actionRemove">
+ <property name="text">
+ <string>Remove</string>
+ </property>
+ </action>
+ <action name="actionMCEdit">
+ <property name="text">
+ <string>MCEdit</string>
+ </property>
+ </action>
+ <action name="actionCopy_Seed">
+ <property name="text">
+ <string>Copy Seed</string>
+ </property>
+ </action>
+ <action name="actionRefresh">
+ <property name="text">
+ <string>Refresh</string>
+ </property>
+ </action>
+ <action name="actionView_Folder">
+ <property name="text">
+ <string>View Folder</string>
+ </property>
+ </action>
+ <action name="actionReset_Icon">
+ <property name="text">
+ <string>Reset Icon</string>
+ </property>
+ <property name="toolTip">
+ <string>Remove world icon to make the game re-generate it on next load.</string>
+ </property>
+ </action>
+ <action name="actionDatapacks">
+ <property name="text">
+ <string>Datapacks</string>
+ </property>
+ <property name="toolTip">
+ <string>Manage datapacks inside the world.</string>
+ </property>
+ </action>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>WideBar</class>
+ <extends>QToolBar</extends>
+ <header>ui/widgets/WideBar.h</header>
+ </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/modplatform/ImportPage.cpp b/meshmc/launcher/ui/pages/modplatform/ImportPage.cpp
new file mode 100644
index 0000000000..e5f2dff7d7
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/ImportPage.cpp
@@ -0,0 +1,136 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ImportPage.h"
+#include "ui_ImportPage.h"
+
+#include <QFileDialog>
+#include <QValidator>
+
+#include "ui/dialogs/NewInstanceDialog.h"
+
+#include "InstanceImportTask.h"
+
+class UrlValidator : public QValidator
+{
+ public:
+ using QValidator::QValidator;
+
+ State validate(QString& in, int& pos) const
+ {
+ const QUrl url(in);
+ if (url.isValid() && !url.isRelative() && !url.isEmpty()) {
+ return Acceptable;
+ } else if (QFile::exists(in)) {
+ return Acceptable;
+ } else {
+ return Intermediate;
+ }
+ }
+};
+
+ImportPage::ImportPage(NewInstanceDialog* dialog, QWidget* parent)
+ : QWidget(parent), ui(new Ui::ImportPage), dialog(dialog)
+{
+ ui->setupUi(this);
+ ui->modpackEdit->setValidator(new UrlValidator(ui->modpackEdit));
+ connect(ui->modpackEdit, &QLineEdit::textChanged, this,
+ &ImportPage::updateState);
+}
+
+ImportPage::~ImportPage()
+{
+ delete ui;
+}
+
+bool ImportPage::shouldDisplay() const
+{
+ return true;
+}
+
+void ImportPage::openedImpl()
+{
+ updateState();
+}
+
+void ImportPage::updateState()
+{
+ if (!isOpened) {
+ return;
+ }
+ if (ui->modpackEdit->hasAcceptableInput()) {
+ QString input = ui->modpackEdit->text();
+ auto url = QUrl::fromUserInput(input);
+ if (url.isLocalFile()) {
+ // FIXME: actually do some validation of what's inside here... this
+ // is fake AF
+ QFileInfo fi(input);
+ if (fi.exists() && fi.suffix() == "zip") {
+ QFileInfo fi(url.fileName());
+ dialog->setSuggestedPack(fi.completeBaseName(),
+ new InstanceImportTask(url));
+ dialog->setSuggestedIcon("default");
+ }
+ } else {
+ if (input.endsWith("?client=y")) {
+ input.chop(9);
+ input.append("/file");
+ url = QUrl::fromUserInput(input);
+ }
+ // hook, line and sinker.
+ QFileInfo fi(url.fileName());
+ dialog->setSuggestedPack(fi.completeBaseName(),
+ new InstanceImportTask(url));
+ dialog->setSuggestedIcon("default");
+ }
+ } else {
+ dialog->setSuggestedPack();
+ }
+}
+
+void ImportPage::setUrl(const QString& url)
+{
+ ui->modpackEdit->setText(url);
+ updateState();
+}
+
+void ImportPage::on_modpackBtn_clicked()
+{
+ const QUrl url = QFileDialog::getOpenFileUrl(
+ this, tr("Choose modpack"), modpackUrl(), tr("Zip (*.zip)"));
+ if (url.isValid()) {
+ if (url.isLocalFile()) {
+ ui->modpackEdit->setText(url.toLocalFile());
+ } else {
+ ui->modpackEdit->setText(url.toString());
+ }
+ }
+}
+
+QUrl ImportPage::modpackUrl() const
+{
+ const QUrl url(ui->modpackEdit->text());
+ if (url.isValid() && !url.isRelative() && !url.host().isEmpty()) {
+ return url;
+ } else {
+ return QUrl::fromLocalFile(ui->modpackEdit->text());
+ }
+}
diff --git a/meshmc/launcher/ui/pages/modplatform/ImportPage.h b/meshmc/launcher/ui/pages/modplatform/ImportPage.h
new file mode 100644
index 0000000000..bc91634d5b
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/ImportPage.h
@@ -0,0 +1,92 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include "tasks/Task.h"
+
+namespace Ui
+{
+ class ImportPage;
+}
+
+class NewInstanceDialog;
+
+class ImportPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit ImportPage(NewInstanceDialog* dialog, QWidget* parent = 0);
+ virtual ~ImportPage();
+ virtual QString displayName() const override
+ {
+ return tr("Import from zip");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("viewfolder");
+ }
+ virtual QString id() const override
+ {
+ return "import";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Zip-import";
+ }
+ virtual bool shouldDisplay() const override;
+
+ void setUrl(const QString& url);
+ void openedImpl() override;
+
+ private slots:
+ void on_modpackBtn_clicked();
+ void updateState();
+
+ private:
+ QUrl modpackUrl() const;
+
+ private:
+ Ui::ImportPage* ui = nullptr;
+ NewInstanceDialog* dialog = nullptr;
+};
diff --git a/meshmc/launcher/ui/pages/modplatform/ImportPage.ui b/meshmc/launcher/ui/pages/modplatform/ImportPage.ui
new file mode 100644
index 0000000000..eb63cbe901
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/ImportPage.ui
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ImportPage</class>
+ <widget class="QWidget" name="ImportPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>546</width>
+ <height>405</height>
+ </rect>
+ </property>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="1" column="1">
+ <widget class="QPushButton" name="modpackBtn">
+ <property name="text">
+ <string>Browse</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLineEdit" name="modpackEdit">
+ <property name="placeholderText">
+ <string notr="true">http://</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0" colspan="2">
+ <widget class="QLabel" name="modpackLabel">
+ <property name="text">
+ <string>Local file or link to a direct download:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0" colspan="2">
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/modplatform/VanillaPage.cpp b/meshmc/launcher/ui/pages/modplatform/VanillaPage.cpp
new file mode 100644
index 0000000000..4b2148353e
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/VanillaPage.cpp
@@ -0,0 +1,128 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "VanillaPage.h"
+#include "ui_VanillaPage.h"
+
+#include <QTabBar>
+
+#include "Application.h"
+#include "meta/Index.h"
+#include "meta/VersionList.h"
+#include "ui/dialogs/NewInstanceDialog.h"
+#include "Filter.h"
+#include "InstanceCreationTask.h"
+
+VanillaPage::VanillaPage(NewInstanceDialog* dialog, QWidget* parent)
+ : QWidget(parent), dialog(dialog), ui(new Ui::VanillaPage)
+{
+ ui->setupUi(this);
+ ui->tabWidget->tabBar()->hide();
+ connect(ui->versionList, &VersionSelectWidget::selectedVersionChanged, this,
+ &VanillaPage::setSelectedVersion);
+ filterChanged();
+ connect(ui->alphaFilter, &QCheckBox::stateChanged, this,
+ &VanillaPage::filterChanged);
+ connect(ui->betaFilter, &QCheckBox::stateChanged, this,
+ &VanillaPage::filterChanged);
+ connect(ui->snapshotFilter, &QCheckBox::stateChanged, this,
+ &VanillaPage::filterChanged);
+ connect(ui->oldSnapshotFilter, &QCheckBox::stateChanged, this,
+ &VanillaPage::filterChanged);
+ connect(ui->releaseFilter, &QCheckBox::stateChanged, this,
+ &VanillaPage::filterChanged);
+ connect(ui->experimentsFilter, &QCheckBox::stateChanged, this,
+ &VanillaPage::filterChanged);
+ connect(ui->refreshBtn, &QPushButton::clicked, this, &VanillaPage::refresh);
+}
+
+void VanillaPage::openedImpl()
+{
+ if (!initialized) {
+ auto vlist = APPLICATION->metadataIndex()->get("net.minecraft");
+ ui->versionList->initialize(vlist.get());
+ initialized = true;
+ } else {
+ suggestCurrent();
+ }
+}
+
+void VanillaPage::refresh()
+{
+ ui->versionList->loadList();
+}
+
+void VanillaPage::filterChanged()
+{
+ QStringList out;
+ if (ui->alphaFilter->isChecked())
+ out << "(old_alpha)";
+ if (ui->betaFilter->isChecked())
+ out << "(old_beta)";
+ if (ui->snapshotFilter->isChecked())
+ out << "(snapshot)";
+ if (ui->oldSnapshotFilter->isChecked())
+ out << "(old_snapshot)";
+ if (ui->releaseFilter->isChecked())
+ out << "(release)";
+ if (ui->experimentsFilter->isChecked())
+ out << "(experiment)";
+ auto regexp = out.join('|');
+ ui->versionList->setFilter(BaseVersionList::TypeRole,
+ new RegexpFilter(regexp, false));
+}
+
+VanillaPage::~VanillaPage()
+{
+ delete ui;
+}
+
+bool VanillaPage::shouldDisplay() const
+{
+ return true;
+}
+
+BaseVersionPtr VanillaPage::selectedVersion() const
+{
+ return m_selectedVersion;
+}
+
+void VanillaPage::suggestCurrent()
+{
+ if (!isOpened) {
+ return;
+ }
+
+ if (!m_selectedVersion) {
+ dialog->setSuggestedPack();
+ return;
+ }
+
+ dialog->setSuggestedPack(m_selectedVersion->descriptor(),
+ new InstanceCreationTask(m_selectedVersion));
+ dialog->setSuggestedIcon("default");
+}
+
+void VanillaPage::setSelectedVersion(BaseVersionPtr version)
+{
+ m_selectedVersion = version;
+ suggestCurrent();
+}
diff --git a/meshmc/launcher/ui/pages/modplatform/VanillaPage.h b/meshmc/launcher/ui/pages/modplatform/VanillaPage.h
new file mode 100644
index 0000000000..8944547877
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/VanillaPage.h
@@ -0,0 +1,98 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include "tasks/Task.h"
+
+namespace Ui
+{
+ class VanillaPage;
+}
+
+class NewInstanceDialog;
+
+class VanillaPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit VanillaPage(NewInstanceDialog* dialog, QWidget* parent = 0);
+ virtual ~VanillaPage();
+ virtual QString displayName() const override
+ {
+ return tr("Vanilla");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("minecraft");
+ }
+ virtual QString id() const override
+ {
+ return "vanilla";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Vanilla-platform";
+ }
+ virtual bool shouldDisplay() const override;
+ void openedImpl() override;
+
+ BaseVersionPtr selectedVersion() const;
+
+ public slots:
+ void setSelectedVersion(BaseVersionPtr version);
+
+ private slots:
+ void filterChanged();
+
+ private:
+ void refresh();
+ void suggestCurrent();
+
+ private:
+ bool initialized = false;
+ NewInstanceDialog* dialog = nullptr;
+ Ui::VanillaPage* ui = nullptr;
+ bool m_versionSetByUser = false;
+ BaseVersionPtr m_selectedVersion;
+};
diff --git a/meshmc/launcher/ui/pages/modplatform/VanillaPage.ui b/meshmc/launcher/ui/pages/modplatform/VanillaPage.ui
new file mode 100644
index 0000000000..870ff1616b
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/VanillaPage.ui
@@ -0,0 +1,169 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>VanillaPage</class>
+ <widget class="QWidget" name="VanillaPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>815</width>
+ <height>607</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_5">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="tab">
+ <attribute name="title">
+ <string notr="true"/>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="1">
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string>Filter</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="releaseFilter">
+ <property name="text">
+ <string>Releases</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="snapshotFilter">
+ <property name="text">
+ <string>Snapshots</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="oldSnapshotFilter">
+ <property name="text">
+ <string>Old Snapshots</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="betaFilter">
+ <property name="text">
+ <string>Betas</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="alphaFilter">
+ <property name="text">
+ <string>Alphas</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="experimentsFilter">
+ <property name="text">
+ <string>Experiments</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QPushButton" name="refreshBtn">
+ <property name="text">
+ <string>Refresh</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="0" column="0">
+ <widget class="VersionSelectWidget" name="versionList" native="true">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>VersionSelectWidget</class>
+ <extends>QWidget</extends>
+ <header>ui/widgets/VersionSelectWidget.h</header>
+ <container>1</container>
+ </customwidget>
+ </customwidgets>
+ <tabstops>
+ <tabstop>tabWidget</tabstop>
+ <tabstop>releaseFilter</tabstop>
+ <tabstop>snapshotFilter</tabstop>
+ <tabstop>oldSnapshotFilter</tabstop>
+ <tabstop>betaFilter</tabstop>
+ <tabstop>alphaFilter</tabstop>
+ <tabstop>experimentsFilter</tabstop>
+ <tabstop>refreshBtn</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.cpp b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.cpp
new file mode 100644
index 0000000000..a89fad941c
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.cpp
@@ -0,0 +1,130 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-2021 Jamie Mansfield <jmansfield@cadixdev.org>
+ *
+ * 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 "AtlFilterModel.h"
+
+#include <QDebug>
+
+#include <modplatform/atlauncher/ATLPackIndex.h>
+#include <Version.h>
+#include <MMCStrings.h>
+
+namespace Atl
+{
+
+ FilterModel::FilterModel(QObject* parent) : QSortFilterProxyModel(parent)
+ {
+ currentSorting = Sorting::ByPopularity;
+ sortings.insert(tr("Sort by popularity"), Sorting::ByPopularity);
+ sortings.insert(tr("Sort by name"), Sorting::ByName);
+ sortings.insert(tr("Sort by game version"), Sorting::ByGameVersion);
+
+ searchTerm = "";
+ }
+
+ const QMap<QString, FilterModel::Sorting>
+ FilterModel::getAvailableSortings()
+ {
+ return sortings;
+ }
+
+ QString FilterModel::translateCurrentSorting()
+ {
+ return sortings.key(currentSorting);
+ }
+
+ void FilterModel::setSorting(Sorting sorting)
+ {
+ currentSorting = sorting;
+ invalidate();
+ }
+
+ FilterModel::Sorting FilterModel::getCurrentSorting()
+ {
+ return currentSorting;
+ }
+
+ void FilterModel::setSearchTerm(const QString term)
+ {
+ searchTerm = term.trimmed();
+ invalidate();
+ }
+
+ bool FilterModel::filterAcceptsRow(int sourceRow,
+ const QModelIndex& sourceParent) const
+ {
+ if (searchTerm.isEmpty()) {
+ return true;
+ }
+
+ QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent);
+ ATLauncher::IndexedPack pack = sourceModel()
+ ->data(index, Qt::UserRole)
+ .value<ATLauncher::IndexedPack>();
+ return pack.name.contains(searchTerm, Qt::CaseInsensitive);
+ }
+
+ bool FilterModel::lessThan(const QModelIndex& left,
+ const QModelIndex& right) const
+ {
+ ATLauncher::IndexedPack leftPack =
+ sourceModel()
+ ->data(left, Qt::UserRole)
+ .value<ATLauncher::IndexedPack>();
+ ATLauncher::IndexedPack rightPack =
+ sourceModel()
+ ->data(right, Qt::UserRole)
+ .value<ATLauncher::IndexedPack>();
+
+ if (currentSorting == ByPopularity) {
+ return leftPack.position > rightPack.position;
+ } else if (currentSorting == ByGameVersion) {
+ Version lv(leftPack.versions.at(0).minecraft);
+ Version rv(rightPack.versions.at(0).minecraft);
+ return lv < rv;
+ } else if (currentSorting == ByName) {
+ return Strings::naturalCompare(leftPack.name, rightPack.name,
+ Qt::CaseSensitive) >= 0;
+ }
+
+ // Invalid sorting set, somehow...
+ qWarning() << "Invalid sorting set!";
+ return true;
+ }
+
+} // namespace Atl
diff --git a/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.h b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.h
new file mode 100644
index 0000000000..7e772e0377
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlFilterModel.h
@@ -0,0 +1,74 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-2021 Jamie Mansfield <jmansfield@cadixdev.org>
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QtCore/QSortFilterProxyModel>
+
+namespace Atl
+{
+
+ class FilterModel : public QSortFilterProxyModel
+ {
+ Q_OBJECT
+ public:
+ FilterModel(QObject* parent = Q_NULLPTR);
+ enum Sorting {
+ ByPopularity,
+ ByGameVersion,
+ ByName,
+ };
+ const QMap<QString, Sorting> getAvailableSortings();
+ QString translateCurrentSorting();
+ void setSorting(Sorting sorting);
+ Sorting getCurrentSorting();
+ void setSearchTerm(QString term);
+
+ protected:
+ bool filterAcceptsRow(int sourceRow,
+ const QModelIndex& sourceParent) const override;
+ bool lessThan(const QModelIndex& left,
+ const QModelIndex& right) const override;
+
+ private:
+ QMap<QString, Sorting> sortings;
+ Sorting currentSorting;
+ QString searchTerm;
+ };
+
+} // namespace Atl
diff --git a/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlListModel.cpp b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlListModel.cpp
new file mode 100644
index 0000000000..7c6129a4a2
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlListModel.cpp
@@ -0,0 +1,234 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-2021 Jamie Mansfield <jmansfield@cadixdev.org>
+ *
+ * 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 "AtlListModel.h"
+
+#include <BuildConfig.h>
+#include <Application.h>
+#include <Json.h>
+
+namespace Atl
+{
+
+ ListModel::ListModel(QObject* 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);
+ }
+
+ ATLauncher::IndexedPack pack = modpacks.at(pos);
+ if (role == Qt::DisplayRole) {
+ return pack.name;
+ } else if (role == Qt::ToolTipRole) {
+ return pack.name;
+ } else if (role == Qt::DecorationRole) {
+ if (m_logoMap.contains(pack.safeName)) {
+ return (m_logoMap.value(pack.safeName));
+ }
+ auto icon = APPLICATION->getThemedIcon("atlauncher-placeholder");
+
+ auto url = QString(BuildConfig.ATL_DOWNLOAD_SERVER_URL +
+ "launcher/images/%1.png")
+ .arg(pack.safeName.toLower());
+ ((ListModel*)this)->requestLogo(pack.safeName, url);
+
+ return icon;
+ } else if (role == Qt::UserRole) {
+ QVariant v;
+ v.setValue(pack);
+ return v;
+ }
+
+ return QVariant();
+ }
+
+ void ListModel::request()
+ {
+ beginResetModel();
+ modpacks.clear();
+ endResetModel();
+
+ auto* netJob = new NetJob("Atl::Request", APPLICATION->network());
+ auto url = QString(BuildConfig.ATL_DOWNLOAD_SERVER_URL +
+ "launcher/json/packsnew.json");
+ netJob->addNetAction(
+ Net::Download::makeByteArray(QUrl(url), &response));
+ jobPtr = netJob;
+ jobPtr->start();
+
+ QObject::connect(netJob, &NetJob::succeeded, this,
+ &ListModel::requestFinished);
+ QObject::connect(netJob, &NetJob::failed, this,
+ &ListModel::requestFailed);
+ }
+
+ void ListModel::requestFinished()
+ {
+ 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 ATL at "
+ << parse_error.offset
+ << " reason: " << parse_error.errorString();
+ qWarning() << response;
+ return;
+ }
+
+ QList<ATLauncher::IndexedPack> newList;
+
+ auto packs = doc.array();
+ for (auto packRaw : packs) {
+ auto packObj = packRaw.toObject();
+
+ ATLauncher::IndexedPack pack;
+
+ try {
+ ATLauncher::loadIndexedPack(pack, packObj);
+ } catch (const JSONValidationError& e) {
+ qDebug() << QString::fromUtf8(response);
+ qWarning()
+ << "Error while reading pack manifest from ATLauncher: "
+ << e.cause();
+ return;
+ }
+
+ // ignore packs without a published version
+ if (pack.versions.length() == 0)
+ continue;
+ // only display public packs (for now)
+ if (pack.type != ATLauncher::PackType::Public)
+ continue;
+ // ignore "system" packs (Vanilla, Vanilla with Forge, etc)
+ if (pack.system)
+ continue;
+
+ newList.append(pack);
+ }
+
+ beginInsertRows(QModelIndex(), modpacks.size(),
+ modpacks.size() + newList.size() - 1);
+ modpacks.append(newList);
+ endInsertRows();
+ }
+
+ void ListModel::requestFailed(QString reason)
+ {
+ jobPtr.reset();
+ }
+
+ void ListModel::getLogo(const QString& logo, const QString& logoUrl,
+ LogoCallback callback)
+ {
+ if (m_logoMap.contains(logo)) {
+ callback(APPLICATION->metacache()
+ ->resolveEntry(
+ "ATLauncherPacks",
+ QString("logos/%1").arg(logo.section(".", 0, 0)))
+ ->getFullPath());
+ } else {
+ requestLogo(logo, logoUrl);
+ }
+ }
+
+ void ListModel::logoFailed(QString logo)
+ {
+ m_failedLogos.append(logo);
+ m_loadingLogos.removeAll(logo);
+ }
+
+ 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].safeName == logo) {
+ emit dataChanged(createIndex(i, 0), createIndex(i, 0),
+ {Qt::DecorationRole});
+ }
+ }
+ }
+
+ void ListModel::requestLogo(QString file, QString url)
+ {
+ if (m_loadingLogos.contains(file) || m_failedLogos.contains(file)) {
+ return;
+ }
+
+ MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry(
+ "ATLauncherPacks",
+ QString("logos/%1").arg(file.section(".", 0, 0)));
+ NetJob* job =
+ new NetJob(QString("ATLauncher Icon Download %1").arg(file),
+ APPLICATION->network());
+ job->addNetAction(Net::Download::makeCached(QUrl(url), entry));
+
+ auto fullPath = entry->getFullPath();
+ QObject::connect(job, &NetJob::succeeded, this, [this, file, fullPath] {
+ emit logoLoaded(file, QIcon(fullPath));
+ if (waitingCallbacks.contains(file)) {
+ waitingCallbacks.value(file)(fullPath);
+ }
+ });
+
+ QObject::connect(job, &NetJob::failed, this,
+ [this, file] { emit logoFailed(file); });
+
+ job->start();
+
+ m_loadingLogos.append(file);
+ }
+
+} // namespace Atl
diff --git a/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlListModel.h b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlListModel.h
new file mode 100644
index 0000000000..172261f60f
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlListModel.h
@@ -0,0 +1,92 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-2021 Jamie Mansfield <jmansfield@cadixdev.org>
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QAbstractListModel>
+
+#include "net/NetJob.h"
+#include <QIcon>
+#include <modplatform/atlauncher/ATLPackIndex.h>
+
+namespace Atl
+{
+
+ typedef QMap<QString, QIcon> LogoMap;
+ typedef std::function<void(QString)> LogoCallback;
+
+ class ListModel : public QAbstractListModel
+ {
+ Q_OBJECT
+
+ public:
+ ListModel(QObject* 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;
+
+ void request();
+
+ void getLogo(const QString& logo, const QString& logoUrl,
+ LogoCallback callback);
+
+ private slots:
+ void requestFinished();
+ void requestFailed(QString reason);
+
+ void logoFailed(QString logo);
+ void logoLoaded(QString logo, QIcon out);
+
+ private:
+ void requestLogo(QString file, QString url);
+
+ private:
+ QList<ATLauncher::IndexedPack> modpacks;
+
+ QStringList m_failedLogos;
+ QStringList m_loadingLogos;
+ LogoMap m_logoMap;
+ QMap<QString, LogoCallback> waitingCallbacks;
+
+ NetJob::Ptr jobPtr;
+ QByteArray response;
+ };
+
+} // namespace Atl
diff --git a/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.cpp b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.cpp
new file mode 100644
index 0000000000..f5f3741cc2
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.cpp
@@ -0,0 +1,266 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2021 Jamie Mansfield <jmansfield@cadixdev.org>
+ *
+ * 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 "AtlOptionalModDialog.h"
+#include "ui_AtlOptionalModDialog.h"
+
+AtlOptionalModListModel::AtlOptionalModListModel(
+ QWidget* parent, QVector<ATLauncher::VersionMod> mods)
+ : QAbstractListModel(parent), m_mods(mods)
+{
+
+ // fill mod index
+ for (int i = 0; i < m_mods.size(); i++) {
+ auto mod = m_mods.at(i);
+ m_index[mod.name] = i;
+ }
+ // set initial state
+ for (int i = 0; i < m_mods.size(); i++) {
+ auto mod = m_mods.at(i);
+ m_selection[mod.name] = false;
+ setMod(mod, i, mod.selected, false);
+ }
+}
+
+QVector<QString> AtlOptionalModListModel::getResult()
+{
+ QVector<QString> result;
+
+ for (const auto& mod : m_mods) {
+ if (m_selection[mod.name]) {
+ result.push_back(mod.name);
+ }
+ }
+
+ return result;
+}
+
+int AtlOptionalModListModel::rowCount(const QModelIndex& parent) const
+{
+ return m_mods.size();
+}
+
+int AtlOptionalModListModel::columnCount(const QModelIndex& parent) const
+{
+ // Enabled, Name, Description
+ return 3;
+}
+
+QVariant AtlOptionalModListModel::data(const QModelIndex& index, int role) const
+{
+ auto row = index.row();
+ auto mod = m_mods.at(row);
+
+ if (role == Qt::DisplayRole) {
+ if (index.column() == NameColumn) {
+ return mod.name;
+ }
+ if (index.column() == DescriptionColumn) {
+ return mod.description;
+ }
+ } else if (role == Qt::ToolTipRole) {
+ if (index.column() == DescriptionColumn) {
+ return mod.description;
+ }
+ } else if (role == Qt::CheckStateRole) {
+ if (index.column() == EnabledColumn) {
+ return m_selection[mod.name] ? Qt::Checked : Qt::Unchecked;
+ }
+ }
+
+ return QVariant();
+}
+
+bool AtlOptionalModListModel::setData(const QModelIndex& index,
+ const QVariant& value, int role)
+{
+ if (role == Qt::CheckStateRole) {
+ auto row = index.row();
+ auto mod = m_mods.at(row);
+
+ toggleMod(mod, row);
+ return true;
+ }
+
+ return false;
+}
+
+QVariant AtlOptionalModListModel::headerData(int section,
+ Qt::Orientation orientation,
+ int role) const
+{
+ if (role == Qt::DisplayRole && orientation == Qt::Horizontal) {
+ switch (section) {
+ case EnabledColumn:
+ return QString();
+ case NameColumn:
+ return QString("Name");
+ case DescriptionColumn:
+ return QString("Description");
+ }
+ }
+
+ return QVariant();
+}
+
+Qt::ItemFlags AtlOptionalModListModel::flags(const QModelIndex& index) const
+{
+ auto flags = QAbstractListModel::flags(index);
+ if (index.isValid() && index.column() == EnabledColumn) {
+ flags |= Qt::ItemIsUserCheckable;
+ }
+ return flags;
+}
+
+void AtlOptionalModListModel::selectRecommended()
+{
+ for (const auto& mod : m_mods) {
+ m_selection[mod.name] = mod.recommended;
+ }
+
+ emit dataChanged(
+ AtlOptionalModListModel::index(0, EnabledColumn),
+ AtlOptionalModListModel::index(m_mods.size() - 1, EnabledColumn));
+}
+
+void AtlOptionalModListModel::clearAll()
+{
+ for (const auto& mod : m_mods) {
+ m_selection[mod.name] = false;
+ }
+
+ emit dataChanged(
+ AtlOptionalModListModel::index(0, EnabledColumn),
+ AtlOptionalModListModel::index(m_mods.size() - 1, EnabledColumn));
+}
+
+void AtlOptionalModListModel::toggleMod(ATLauncher::VersionMod mod, int index)
+{
+ setMod(mod, index, !m_selection[mod.name]);
+}
+
+void AtlOptionalModListModel::setMod(ATLauncher::VersionMod mod, int index,
+ bool enable, bool shouldEmit)
+{
+ if (m_selection[mod.name] == enable)
+ return;
+
+ m_selection[mod.name] = enable;
+
+ // disable other mods in the group, if applicable
+ if (enable && !mod.group.isEmpty()) {
+ for (int i = 0; i < m_mods.size(); i++) {
+ if (index == i)
+ continue;
+ auto other = m_mods.at(i);
+
+ if (mod.group == other.group) {
+ setMod(other, i, false, shouldEmit);
+ }
+ }
+ }
+
+ for (const auto& dependencyName : mod.depends) {
+ auto dependencyIndex = m_index[dependencyName];
+ auto dependencyMod = m_mods.at(dependencyIndex);
+
+ // enable/disable dependencies
+ if (enable) {
+ setMod(dependencyMod, dependencyIndex, true, shouldEmit);
+ }
+
+ // if the dependency is 'effectively hidden', then track which mods
+ // depend on it - so we can efficiently disable it when no more
+ // dependents depend on it.
+ auto dependants = m_dependants[dependencyName];
+
+ if (enable) {
+ dependants.append(mod.name);
+ } else {
+ dependants.removeAll(mod.name);
+
+ // if there are no longer any dependents, let's disable the mod
+ if (dependencyMod.effectively_hidden && dependants.isEmpty()) {
+ setMod(dependencyMod, dependencyIndex, false, shouldEmit);
+ }
+ }
+ }
+
+ // disable mods that depend on this one, if disabling
+ if (!enable) {
+ auto dependants = m_dependants[mod.name];
+ for (const auto& dependencyName : dependants) {
+ auto dependencyIndex = m_index[dependencyName];
+ auto dependencyMod = m_mods.at(dependencyIndex);
+
+ setMod(dependencyMod, dependencyIndex, false, shouldEmit);
+ }
+ }
+
+ if (shouldEmit) {
+ emit dataChanged(AtlOptionalModListModel::index(index, EnabledColumn),
+ AtlOptionalModListModel::index(index, EnabledColumn));
+ }
+}
+
+AtlOptionalModDialog::AtlOptionalModDialog(QWidget* parent,
+ QVector<ATLauncher::VersionMod> mods)
+ : QDialog(parent), ui(new Ui::AtlOptionalModDialog)
+{
+ ui->setupUi(this);
+
+ listModel = new AtlOptionalModListModel(this, mods);
+ ui->treeView->setModel(listModel);
+
+ ui->treeView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ ui->treeView->header()->setSectionResizeMode(
+ AtlOptionalModListModel::NameColumn, QHeaderView::ResizeToContents);
+ ui->treeView->header()->setSectionResizeMode(
+ AtlOptionalModListModel::DescriptionColumn, QHeaderView::Stretch);
+
+ connect(ui->selectRecommendedButton, &QPushButton::pressed, listModel,
+ &AtlOptionalModListModel::selectRecommended);
+ connect(ui->clearAllButton, &QPushButton::pressed, listModel,
+ &AtlOptionalModListModel::clearAll);
+ connect(ui->installButton, &QPushButton::pressed, this, &QDialog::close);
+}
+
+AtlOptionalModDialog::~AtlOptionalModDialog()
+{
+ delete ui;
+}
diff --git a/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.h b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.h
new file mode 100644
index 0000000000..1f21426328
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.h
@@ -0,0 +1,111 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2021 Jamie Mansfield <jmansfield@cadixdev.org>
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <QAbstractListModel>
+
+#include "modplatform/atlauncher/ATLPackIndex.h"
+
+namespace Ui
+{
+ class AtlOptionalModDialog;
+}
+
+class AtlOptionalModListModel : public QAbstractListModel
+{
+ Q_OBJECT
+
+ public:
+ enum Columns {
+ EnabledColumn = 0,
+ NameColumn,
+ DescriptionColumn,
+ };
+
+ AtlOptionalModListModel(QWidget* parent,
+ QVector<ATLauncher::VersionMod> mods);
+
+ QVector<QString> getResult();
+
+ int rowCount(const QModelIndex& parent) const override;
+ int columnCount(const QModelIndex& parent) const override;
+
+ QVariant data(const QModelIndex& index, int role) const override;
+ bool setData(const QModelIndex& index, const QVariant& value,
+ int role) override;
+ QVariant headerData(int section, Qt::Orientation orientation,
+ int role) const override;
+
+ Qt::ItemFlags flags(const QModelIndex& index) const override;
+
+ public slots:
+ void selectRecommended();
+ void clearAll();
+
+ private:
+ void toggleMod(ATLauncher::VersionMod mod, int index);
+ void setMod(ATLauncher::VersionMod mod, int index, bool enable,
+ bool shouldEmit = true);
+
+ private:
+ QVector<ATLauncher::VersionMod> m_mods;
+ QMap<QString, bool> m_selection;
+ QMap<QString, int> m_index;
+ QMap<QString, QVector<QString>> m_dependants;
+};
+
+class AtlOptionalModDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ AtlOptionalModDialog(QWidget* parent, QVector<ATLauncher::VersionMod> mods);
+ ~AtlOptionalModDialog() override;
+
+ QVector<QString> getResult()
+ {
+ return listModel->getResult();
+ }
+
+ private:
+ Ui::AtlOptionalModDialog* ui;
+
+ AtlOptionalModListModel* listModel;
+};
diff --git a/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.ui b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.ui
new file mode 100644
index 0000000000..4c5c2ec5ec
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlOptionalModDialog.ui
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>AtlOptionalModDialog</class>
+ <widget class="QDialog" name="AtlOptionalModDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>550</width>
+ <height>310</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Select Mods To Install</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="1" column="3">
+ <widget class="QPushButton" name="installButton">
+ <property name="text">
+ <string>Install</string>
+ </property>
+ <property name="default">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QPushButton" name="selectRecommendedButton">
+ <property name="text">
+ <string>Select Recommended</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QPushButton" name="shareCodeButton">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string>Use Share Code</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="2">
+ <widget class="QPushButton" name="clearAllButton">
+ <property name="text">
+ <string>Clear All</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0" colspan="4">
+ <widget class="ModListView" name="treeView"/>
+ </item>
+ </layout>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>ModListView</class>
+ <extends>QTreeView</extends>
+ <header>ui/widgets/ModListView.h</header>
+ </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlPage.cpp b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlPage.cpp
new file mode 100644
index 0000000000..deaf83a5ea
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlPage.cpp
@@ -0,0 +1,227 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-2021 Jamie Mansfield <jmansfield@cadixdev.org>
+ * Copyright 2021 Philip T <me@phit.link>
+ *
+ * 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 "AtlPage.h"
+#include "ui_AtlPage.h"
+
+#include "modplatform/atlauncher/ATLPackInstallTask.h"
+
+#include "AtlOptionalModDialog.h"
+#include "ui/dialogs/NewInstanceDialog.h"
+#include "ui/dialogs/VersionSelectDialog.h"
+
+#include <BuildConfig.h>
+
+AtlPage::AtlPage(NewInstanceDialog* dialog, QWidget* parent)
+ : QWidget(parent), ui(new Ui::AtlPage), dialog(dialog)
+{
+ ui->setupUi(this);
+
+ filterModel = new Atl::FilterModel(this);
+ listModel = new Atl::ListModel(this);
+ filterModel->setSourceModel(listModel);
+ ui->packView->setModel(filterModel);
+ ui->packView->setSortingEnabled(true);
+
+ ui->packView->header()->hide();
+ ui->packView->setIndentation(0);
+
+ ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(
+ Qt::ScrollBarAsNeeded);
+ ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300);
+
+ for (int i = 0; i < filterModel->getAvailableSortings().size(); i++) {
+ ui->sortByBox->addItem(
+ filterModel->getAvailableSortings().keys().at(i));
+ }
+ ui->sortByBox->setCurrentText(filterModel->translateCurrentSorting());
+
+ connect(ui->searchEdit, &QLineEdit::textChanged, this,
+ &AtlPage::triggerSearch);
+ connect(ui->sortByBox, &QComboBox::currentTextChanged, this,
+ &AtlPage::onSortingSelectionChanged);
+ connect(ui->packView->selectionModel(),
+ &QItemSelectionModel::currentChanged, this,
+ &AtlPage::onSelectionChanged);
+ connect(ui->versionSelectionBox, &QComboBox::currentTextChanged, this,
+ &AtlPage::onVersionSelectionChanged);
+}
+
+AtlPage::~AtlPage()
+{
+ delete ui;
+}
+
+bool AtlPage::shouldDisplay() const
+{
+ return true;
+}
+
+void AtlPage::openedImpl()
+{
+ if (!initialized) {
+ listModel->request();
+ initialized = true;
+ }
+
+ suggestCurrent();
+}
+
+void AtlPage::suggestCurrent()
+{
+ if (!isOpened) {
+ return;
+ }
+
+ if (selectedVersion.isEmpty()) {
+ dialog->setSuggestedPack();
+ return;
+ }
+
+ dialog->setSuggestedPack(selected.name + " " + selectedVersion,
+ new ATLauncher::PackInstallTask(
+ this, selected.safeName, selectedVersion));
+ auto editedLogoName = selected.safeName;
+ auto url =
+ QString(BuildConfig.ATL_DOWNLOAD_SERVER_URL + "launcher/images/%1.png")
+ .arg(selected.safeName.toLower());
+ listModel->getLogo(
+ selected.safeName, url, [this, editedLogoName](QString logo) {
+ dialog->setSuggestedIconFromFile(logo, editedLogoName);
+ });
+}
+
+void AtlPage::triggerSearch()
+{
+ filterModel->setSearchTerm(ui->searchEdit->text());
+}
+
+void AtlPage::onSortingSelectionChanged(QString data)
+{
+ auto toSet = filterModel->getAvailableSortings().value(data);
+ filterModel->setSorting(toSet);
+}
+
+void AtlPage::onSelectionChanged(QModelIndex first, QModelIndex second)
+{
+ ui->versionSelectionBox->clear();
+
+ if (!first.isValid()) {
+ if (isOpened) {
+ dialog->setSuggestedPack();
+ }
+ return;
+ }
+
+ selected =
+ filterModel->data(first, Qt::UserRole).value<ATLauncher::IndexedPack>();
+
+ ui->packDescription->setHtml(selected.description.replace("\n", "<br>"));
+
+ for (const auto& version : selected.versions) {
+ ui->versionSelectionBox->addItem(version.version);
+ }
+
+ suggestCurrent();
+}
+
+void AtlPage::onVersionSelectionChanged(QString data)
+{
+ if (data.isNull() || data.isEmpty()) {
+ selectedVersion = "";
+ return;
+ }
+
+ selectedVersion = data;
+ suggestCurrent();
+}
+
+QVector<QString>
+AtlPage::chooseOptionalMods(QVector<ATLauncher::VersionMod> mods)
+{
+ AtlOptionalModDialog optionalModDialog(this, mods);
+ optionalModDialog.exec();
+ return optionalModDialog.getResult();
+}
+
+QString AtlPage::chooseVersion(Meta::VersionListPtr vlist,
+ QString minecraftVersion)
+{
+ VersionSelectDialog vselect(vlist.get(), "Choose Version",
+ APPLICATION->activeWindow(), false);
+ if (minecraftVersion != Q_NULLPTR) {
+ vselect.setExactFilter(BaseVersionList::ParentVersionRole,
+ minecraftVersion);
+ vselect.setEmptyString(
+ tr("No versions are currently available for Minecraft %1")
+ .arg(minecraftVersion));
+ } else {
+ vselect.setEmptyString(tr("No versions are currently available"));
+ }
+ vselect.setEmptyErrorString(
+ tr("Couldn't load or download the version lists!"));
+
+ // select recommended build
+ for (int i = 0; i < vlist->versions().size(); i++) {
+ auto version = vlist->versions().at(i);
+ auto reqs = version->requirements();
+
+ // filter by minecraft version, if the loader depends on a certain
+ // version.
+ if (minecraftVersion != Q_NULLPTR) {
+ auto iter = std::find_if(reqs.begin(), reqs.end(),
+ [](const Meta::Require& req) {
+ return req.uid == "net.minecraft";
+ });
+ if (iter == reqs.end())
+ continue;
+ if (iter->equalsVersion != minecraftVersion)
+ continue;
+ }
+
+ // first recommended build we find, we use.
+ if (version->isRecommended()) {
+ vselect.setCurrentVersion(version->descriptor());
+ break;
+ }
+ }
+
+ vselect.exec();
+ return vselect.selectedVersion()->descriptor();
+}
diff --git a/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlPage.h b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlPage.h
new file mode 100644
index 0000000000..de51abf391
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlPage.h
@@ -0,0 +1,113 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-2021 Jamie Mansfield <jmansfield@cadixdev.org>
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include "AtlFilterModel.h"
+#include "AtlListModel.h"
+
+#include <QWidget>
+#include <modplatform/atlauncher/ATLPackInstallTask.h>
+
+#include "Application.h"
+#include "ui/pages/BasePage.h"
+#include "tasks/Task.h"
+
+namespace Ui
+{
+ class AtlPage;
+}
+
+class NewInstanceDialog;
+
+class AtlPage : public QWidget,
+ public BasePage,
+ public ATLauncher::UserInteractionSupport
+{
+ Q_OBJECT
+
+ public:
+ explicit AtlPage(NewInstanceDialog* dialog, QWidget* parent = 0);
+ virtual ~AtlPage();
+ virtual QString displayName() const override
+ {
+ return tr("ATLauncher");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("atlauncher");
+ }
+ virtual QString id() const override
+ {
+ return "atl";
+ }
+ virtual QString helpPage() const override
+ {
+ return "ATL-platform";
+ }
+ virtual bool shouldDisplay() const override;
+
+ void openedImpl() override;
+
+ private:
+ void suggestCurrent();
+
+ QString chooseVersion(Meta::VersionListPtr vlist,
+ QString minecraftVersion) override;
+ QVector<QString>
+ chooseOptionalMods(QVector<ATLauncher::VersionMod> mods) override;
+
+ private slots:
+ void triggerSearch();
+
+ void onSortingSelectionChanged(QString data);
+
+ void onSelectionChanged(QModelIndex first, QModelIndex second);
+ void onVersionSelectionChanged(QString data);
+
+ private:
+ Ui::AtlPage* ui = nullptr;
+ NewInstanceDialog* dialog = nullptr;
+ Atl::ListModel* listModel = nullptr;
+ Atl::FilterModel* filterModel = nullptr;
+
+ ATLauncher::IndexedPack selected;
+ QString selectedVersion;
+
+ bool initialized = false;
+};
diff --git a/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlPage.ui b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlPage.ui
new file mode 100644
index 0000000000..9085766a6c
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/atlauncher/AtlPage.ui
@@ -0,0 +1,92 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>AtlPage</class>
+ <widget class="QWidget" name="AtlPage">
+ <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="QTreeView" name="packView">
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>96</width>
+ <height>48</height>
+ </size>
+ </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>
+ <item row="0" column="0" colspan="2">
+ <widget class="QLabel" name="label_2">
+ <property name="text">
+ <string>Warning: This is still a work in progress. If you run into issues with the imported modpack, it may be a bug.</string>
+ </property>
+ <property name="wordWrap">
+ <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="0">
+ <widget class="QLineEdit" name="searchEdit">
+ <property name="placeholderText">
+ <string>Search and filter ...</string>
+ </property>
+ <property name="clearButtonEnabled">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>searchEdit</tabstop>
+ <tabstop>packView</tabstop>
+ <tabstop>packDescription</tabstop>
+ <tabstop>sortByBox</tabstop>
+ <tabstop>versionSelectionBox</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/modplatform/flame/FlameModel.cpp b/meshmc/launcher/ui/pages/modplatform/flame/FlameModel.cpp
new file mode 100644
index 0000000000..899c6a3884
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/flame/FlameModel.cpp
@@ -0,0 +1,298 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "FlameModel.h"
+#include "Application.h"
+#include <Json.h>
+
+#include <MMCStrings.h>
+#include <Version.h>
+
+#include <QtMath>
+#include <QLabel>
+
+#include <RWStorage.h>
+
+namespace Flame
+{
+
+ ListModel::ListModel(QObject* 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(
+ "FlamePacks", QString("logos/%1").arg(logo.section(".", 0, 0)));
+ NetJob* 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] {
+ emit logoLoaded(logo, QIcon(fullPath));
+ if (waitingCallbacks.contains(logo)) {
+ waitingCallbacks.value(logo)(fullPath);
+ }
+ });
+
+ QObject::connect(job, &NetJob::failed, this,
+ [this, logo] { 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(
+ "FlamePacks",
+ 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();
+ }
+
+ void ListModel::performPaginatedSearch()
+ {
+ // API v1 sort fields (1-indexed): 1=Featured, 2=Popularity,
+ // 3=LastUpdated, 4=Name, 5=Author, 6=TotalDownloads Use desc for
+ // Featured/Popularity/LastUpdated/TotalDownloads, asc for Name/Author
+ // (A-Z)
+ static const char* sortOrders[] = {"desc", "desc", "desc",
+ "asc", "asc", "desc"};
+ const char* sortOrder = (currentSort >= 0 && currentSort < 6)
+ ? sortOrders[currentSort]
+ : "desc";
+
+ NetJob* netJob = new NetJob("Flame::Search", APPLICATION->network());
+ auto searchUrl = QString("https://api.curseforge.com/v1/mods/search?"
+ "gameId=432&"
+ "classId=4471&"
+ "index=%1&"
+ "pageSize=25&"
+ "searchFilter=%2&"
+ "sortField=%3&"
+ "sortOrder=%4")
+ .arg(nextSearchOffset)
+ .arg(currentSearchTerm)
+ .arg(currentSort + 1)
+ .arg(sortOrder);
+ 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, 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 Flame::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 CurseForge at "
+ << parse_error.offset
+ << " reason: " << parse_error.errorString();
+ qWarning() << response;
+ return;
+ }
+
+ QList<Flame::IndexedPack> newList;
+ // CurseForge API v1 wraps results in {"data": [...], "pagination":
+ // {...}}
+ QJsonArray packs;
+ if (doc.isObject() && doc.object().contains("data")) {
+ packs = doc.object().value("data").toArray();
+ qDebug() << "CurseForge: parsed" << packs.size()
+ << "packs from 'data' key";
+ } else {
+ packs = doc.array();
+ qDebug() << "CurseForge: parsed" << packs.size()
+ << "packs from root array";
+ }
+ qDebug() << "CurseForge raw response (first 500 chars):"
+ << response.left(500);
+ for (auto packRaw : packs) {
+ auto packObj = packRaw.toObject();
+
+ Flame::IndexedPack pack;
+ try {
+ Flame::loadIndexedPack(pack, packObj);
+ newList.append(pack);
+ } catch (const JSONValidationError& e) {
+ qWarning() << "Error while loading pack from CurseForge: "
+ << 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 Flame::ListModel::searchRequestFailed(QString reason)
+ {
+ jobPtr.reset();
+
+ if (searchState == ResetRequested) {
+ beginResetModel();
+ modpacks.clear();
+ endResetModel();
+
+ nextSearchOffset = 0;
+ performPaginatedSearch();
+ } else {
+ searchState = Finished;
+ }
+ }
+
+} // namespace Flame
diff --git a/meshmc/launcher/ui/pages/modplatform/flame/FlameModel.h b/meshmc/launcher/ui/pages/modplatform/flame/FlameModel.h
new file mode 100644
index 0000000000..166cb9f147
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/flame/FlameModel.h
@@ -0,0 +1,98 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#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>
+
+namespace Flame
+{
+
+ typedef QMap<QString, QIcon> LogoMap;
+ typedef std::function<void(QString)> LogoCallback;
+
+ class ListModel : public QAbstractListModel
+ {
+ Q_OBJECT
+
+ public:
+ ListModel(QObject* 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;
+ };
+
+} // namespace Flame
diff --git a/meshmc/launcher/ui/pages/modplatform/flame/FlamePage.cpp b/meshmc/launcher/ui/pages/modplatform/flame/FlamePage.cpp
new file mode 100644
index 0000000000..4a92a95d07
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/flame/FlamePage.cpp
@@ -0,0 +1,246 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "FlamePage.h"
+#include "ui_FlamePage.h"
+
+#include <QKeyEvent>
+
+#include "Application.h"
+#include "Json.h"
+#include "ui/dialogs/NewInstanceDialog.h"
+#include "InstanceImportTask.h"
+#include "FlameModel.h"
+
+FlamePage::FlamePage(NewInstanceDialog* dialog, QWidget* parent)
+ : QWidget(parent), ui(new Ui::FlamePage), dialog(dialog)
+{
+ ui->setupUi(this);
+ connect(ui->searchButton, &QPushButton::clicked, this,
+ &FlamePage::triggerSearch);
+ ui->searchEdit->installEventFilter(this);
+ listModel = new Flame::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 curseforge 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 total downloads"));
+
+ connect(ui->sortByBox, SIGNAL(currentIndexChanged(int)), this,
+ SLOT(triggerSearch()));
+ connect(ui->packView->selectionModel(),
+ &QItemSelectionModel::currentChanged, this,
+ &FlamePage::onSelectionChanged);
+ connect(ui->versionSelectionBox, &QComboBox::currentTextChanged, this,
+ &FlamePage::onVersionSelectionChanged);
+}
+
+FlamePage::~FlamePage()
+{
+ delete ui;
+}
+
+bool FlamePage::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 FlamePage::shouldDisplay() const
+{
+ return true;
+}
+
+void FlamePage::openedImpl()
+{
+ suggestCurrent();
+ triggerSearch();
+}
+
+void FlamePage::triggerSearch()
+{
+ listModel->searchWithTerm(ui->searchEdit->text(),
+ ui->sortByBox->currentIndex());
+}
+
+void FlamePage::onSelectionChanged(QModelIndex first, QModelIndex second)
+{
+ ui->versionSelectionBox->clear();
+
+ if (!first.isValid()) {
+ if (isOpened) {
+ dialog->setSuggestedPack();
+ }
+ return;
+ }
+
+ current = listModel->data(first, Qt::UserRole).value<Flame::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 = [](Flame::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 (isOpened) {
+ dialog->setSuggestedPack(current.name);
+ }
+
+ if (current.versionsLoaded == false) {
+ qDebug() << "Loading flame modpack versions";
+ NetJob* netJob =
+ new NetJob(QString("Flame::PackVersions(%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://api.curseforge.com/v1/mods/%1/files").arg(addonId),
+ response.get()));
+
+ QObject::connect(netJob, &NetJob::succeeded, this, [this, response] {
+ QJsonParseError parse_error;
+ QJsonDocument doc =
+ QJsonDocument::fromJson(*response, &parse_error);
+ if (parse_error.error != QJsonParseError::NoError) {
+ qWarning()
+ << "Error while parsing JSON response from CurseForge at "
+ << parse_error.offset
+ << " reason: " << parse_error.errorString();
+ qWarning() << *response;
+ return;
+ }
+ QJsonArray arr;
+ if (doc.isObject() && doc.object().contains("data")) {
+ arr = doc.object().value("data").toArray();
+ } else {
+ arr = doc.array();
+ }
+ try {
+ Flame::loadIndexedPackVersions(current, arr);
+ } catch (const JSONValidationError& e) {
+ qDebug() << *response;
+ qWarning() << "Error while reading flame modpack version: "
+ << e.cause();
+ }
+
+ for (auto version : current.versions) {
+ ui->versionSelectionBox->addItem(version.version,
+ QVariant(version.downloadUrl));
+ }
+
+ suggestCurrent();
+ });
+ netJob->start();
+ } else {
+ for (auto version : current.versions) {
+ ui->versionSelectionBox->addItem(version.version,
+ QVariant(version.downloadUrl));
+ }
+
+ suggestCurrent();
+ }
+}
+
+void FlamePage::suggestCurrent()
+{
+ if (!isOpened) {
+ return;
+ }
+
+ if (selectedVersionIndex < 0 ||
+ selectedVersionIndex >= current.versions.size()) {
+ dialog->setSuggestedPack();
+ return;
+ }
+
+ auto& version = current.versions[selectedVersionIndex];
+
+ if (!version.downloadUrl.isEmpty()) {
+ // Normal download — direct URL available
+ dialog->setSuggestedPack(current.name,
+ new InstanceImportTask(version.downloadUrl));
+ } else {
+ // Restricted download — construct CurseForge browser download URL
+ // This URL triggers a browser download when opened, respecting ToS
+ QString browserUrl =
+ QString(
+ "https://www.curseforge.com/api/v1/mods/%1/files/%2/download")
+ .arg(version.addonId)
+ .arg(version.fileId);
+ dialog->setSuggestedPack(current.name,
+ new InstanceImportTask(browserUrl));
+ qDebug() << "Pack has no API download URL, using browser download URL:"
+ << browserUrl;
+ }
+
+ QString editedLogoName;
+ editedLogoName = "curseforge_" + current.logoName.section(".", 0, 0);
+ listModel->getLogo(current.logoName, current.logoUrl,
+ [this, editedLogoName](QString logo) {
+ dialog->setSuggestedIconFromFile(logo,
+ editedLogoName);
+ });
+}
+
+void FlamePage::onVersionSelectionChanged(QString data)
+{
+ if (data.isNull() || data.isEmpty()) {
+ selectedVersion = "";
+ selectedVersionIndex = -1;
+ return;
+ }
+ selectedVersion = ui->versionSelectionBox->currentData().toString();
+ selectedVersionIndex = ui->versionSelectionBox->currentIndex();
+ suggestCurrent();
+}
diff --git a/meshmc/launcher/ui/pages/modplatform/flame/FlamePage.h b/meshmc/launcher/ui/pages/modplatform/flame/FlamePage.h
new file mode 100644
index 0000000000..cbdaaec194
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/flame/FlamePage.h
@@ -0,0 +1,105 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include "tasks/Task.h"
+#include <modplatform/flame/FlamePackIndex.h>
+
+namespace Ui
+{
+ class FlamePage;
+}
+
+class NewInstanceDialog;
+
+namespace Flame
+{
+ class ListModel;
+}
+
+class FlamePage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit FlamePage(NewInstanceDialog* dialog, QWidget* parent = 0);
+ virtual ~FlamePage();
+ virtual QString displayName() const override
+ {
+ return tr("CurseForge");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("flame");
+ }
+ virtual QString id() const override
+ {
+ return "flame";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Flame-platform";
+ }
+ virtual bool shouldDisplay() const override;
+
+ void openedImpl() override;
+
+ bool eventFilter(QObject* watched, QEvent* event) override;
+
+ private:
+ void suggestCurrent();
+
+ private slots:
+ void triggerSearch();
+ void onSelectionChanged(QModelIndex first, QModelIndex second);
+ void onVersionSelectionChanged(QString data);
+
+ private:
+ Ui::FlamePage* ui = nullptr;
+ NewInstanceDialog* dialog = nullptr;
+ Flame::ListModel* listModel = nullptr;
+ Flame::IndexedPack current;
+
+ QString selectedVersion;
+ int selectedVersionIndex = -1;
+};
diff --git a/meshmc/launcher/ui/pages/modplatform/flame/FlamePage.ui b/meshmc/launcher/ui/pages/modplatform/flame/FlamePage.ui
new file mode 100644
index 0000000000..9723815a6f
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/flame/FlamePage.ui
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>FlamePage</class>
+ <widget class="QWidget" name="FlamePage">
+ <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/meshmc/launcher/ui/pages/modplatform/ftb/FtbFilterModel.cpp b/meshmc/launcher/ui/pages/modplatform/ftb/FtbFilterModel.cpp
new file mode 100644
index 0000000000..38f0293101
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/ftb/FtbFilterModel.cpp
@@ -0,0 +1,123 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-2021 Jamie Mansfield <jmansfield@cadixdev.org>
+ *
+ * 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 "FtbFilterModel.h"
+
+#include <QDebug>
+
+#include "modplatform/modpacksch/FTBPackManifest.h"
+#include <MMCStrings.h>
+
+namespace Ftb
+{
+
+ FilterModel::FilterModel(QObject* parent) : QSortFilterProxyModel(parent)
+ {
+ currentSorting = Sorting::ByPlays;
+ sortings.insert(tr("Sort by plays"), Sorting::ByPlays);
+ sortings.insert(tr("Sort by installs"), Sorting::ByInstalls);
+ sortings.insert(tr("Sort by name"), Sorting::ByName);
+ }
+
+ const QMap<QString, FilterModel::Sorting>
+ FilterModel::getAvailableSortings()
+ {
+ return sortings;
+ }
+
+ QString FilterModel::translateCurrentSorting()
+ {
+ return sortings.key(currentSorting);
+ }
+
+ void FilterModel::setSorting(Sorting sorting)
+ {
+ currentSorting = sorting;
+ invalidate();
+ }
+
+ FilterModel::Sorting FilterModel::getCurrentSorting()
+ {
+ return currentSorting;
+ }
+
+ void FilterModel::setSearchTerm(const QString& term)
+ {
+ searchTerm = term.trimmed();
+ invalidate();
+ }
+
+ bool FilterModel::filterAcceptsRow(int sourceRow,
+ const QModelIndex& sourceParent) const
+ {
+ if (searchTerm.isEmpty()) {
+ return true;
+ }
+
+ auto index = sourceModel()->index(sourceRow, 0, sourceParent);
+ auto pack = sourceModel()
+ ->data(index, Qt::UserRole)
+ .value<ModpacksCH::Modpack>();
+ return pack.name.contains(searchTerm, Qt::CaseInsensitive);
+ }
+
+ bool FilterModel::lessThan(const QModelIndex& left,
+ const QModelIndex& right) const
+ {
+ ModpacksCH::Modpack leftPack = sourceModel()
+ ->data(left, Qt::UserRole)
+ .value<ModpacksCH::Modpack>();
+ ModpacksCH::Modpack rightPack = sourceModel()
+ ->data(right, Qt::UserRole)
+ .value<ModpacksCH::Modpack>();
+
+ if (currentSorting == ByPlays) {
+ return leftPack.plays < rightPack.plays;
+ } else if (currentSorting == ByInstalls) {
+ return leftPack.installs < rightPack.installs;
+ } else if (currentSorting == ByName) {
+ return Strings::naturalCompare(leftPack.name, rightPack.name,
+ Qt::CaseSensitive) >= 0;
+ }
+
+ // Invalid sorting set, somehow...
+ qWarning() << "Invalid sorting set!";
+ return true;
+ }
+
+} // namespace Ftb
diff --git a/meshmc/launcher/ui/pages/modplatform/ftb/FtbFilterModel.h b/meshmc/launcher/ui/pages/modplatform/ftb/FtbFilterModel.h
new file mode 100644
index 0000000000..e5d2f3b10a
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/ftb/FtbFilterModel.h
@@ -0,0 +1,75 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-2021 Jamie Mansfield <jmansfield@cadixdev.org>
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QtCore/QSortFilterProxyModel>
+
+namespace Ftb
+{
+
+ class FilterModel : public QSortFilterProxyModel
+ {
+ Q_OBJECT
+
+ public:
+ FilterModel(QObject* parent = Q_NULLPTR);
+ enum Sorting {
+ ByPlays,
+ ByInstalls,
+ ByName,
+ };
+ const QMap<QString, Sorting> getAvailableSortings();
+ QString translateCurrentSorting();
+ void setSorting(Sorting sorting);
+ Sorting getCurrentSorting();
+ void setSearchTerm(const QString& term);
+
+ protected:
+ bool filterAcceptsRow(int sourceRow,
+ const QModelIndex& sourceParent) const override;
+ bool lessThan(const QModelIndex& left,
+ const QModelIndex& right) const override;
+
+ private:
+ QMap<QString, Sorting> sortings;
+ Sorting currentSorting;
+ QString searchTerm{""};
+ };
+
+} // namespace Ftb
diff --git a/meshmc/launcher/ui/pages/modplatform/ftb/FtbListModel.cpp b/meshmc/launcher/ui/pages/modplatform/ftb/FtbListModel.cpp
new file mode 100644
index 0000000000..c873d2b9bd
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/ftb/FtbListModel.cpp
@@ -0,0 +1,318 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-2021 Jamie Mansfield <jmansfield@cadixdev.org>
+ *
+ * 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 "FtbListModel.h"
+
+#include "BuildConfig.h"
+#include "Application.h"
+#include "Json.h"
+
+#include <QPainter>
+
+namespace Ftb
+{
+
+ ListModel::ListModel(QObject* 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);
+ }
+
+ ModpacksCH::Modpack pack = modpacks.at(pos);
+ if (role == Qt::DisplayRole) {
+ return pack.name;
+ } else if (role == Qt::ToolTipRole) {
+ return pack.synopsis;
+ } else if (role == Qt::DecorationRole) {
+ QIcon placeholder =
+ APPLICATION->getThemedIcon("screenshot-placeholder");
+
+ auto iter = m_logoMap.find(pack.name);
+ if (iter != m_logoMap.end()) {
+ auto& logo = *iter;
+ if (!logo.result.isNull()) {
+ return logo.result;
+ }
+ return placeholder;
+ }
+
+ for (auto art : pack.art) {
+ if (art.type == "square") {
+ ((ListModel*)this)->requestLogo(pack.name, art.url);
+ }
+ }
+ return placeholder;
+ } else if (role == Qt::UserRole) {
+ QVariant v;
+ v.setValue(pack);
+ return v;
+ }
+
+ return QVariant();
+ }
+
+ void ListModel::getLogo(const QString& logo, const QString& logoUrl,
+ LogoCallback callback)
+ {
+ if (m_logoMap.contains(logo)) {
+ callback(APPLICATION->metacache()
+ ->resolveEntry(
+ "ModpacksCHPacks",
+ QString("logos/%1").arg(logo.section(".", 0, 0)))
+ ->getFullPath());
+ } else {
+ requestLogo(logo, logoUrl);
+ }
+ }
+
+ void ListModel::request()
+ {
+ beginResetModel();
+ modpacks.clear();
+ endResetModel();
+
+ auto* netJob = new NetJob("Ftb::Request", APPLICATION->network());
+ auto url =
+ QString(BuildConfig.MODPACKSCH_API_BASE_URL + "public/modpack/all");
+ netJob->addNetAction(
+ Net::Download::makeByteArray(QUrl(url), &response));
+ jobPtr = netJob;
+ jobPtr->start();
+
+ QObject::connect(netJob, &NetJob::succeeded, this,
+ &ListModel::requestFinished);
+ QObject::connect(netJob, &NetJob::failed, this,
+ &ListModel::requestFailed);
+ }
+
+ void ListModel::requestFinished()
+ {
+ jobPtr.reset();
+ remainingPacks.clear();
+
+ QJsonParseError parse_error;
+ QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error);
+ if (parse_error.error != QJsonParseError::NoError) {
+ qWarning() << "Error while parsing JSON response from FTB at "
+ << parse_error.offset
+ << " reason: " << parse_error.errorString();
+ qWarning() << response;
+ return;
+ }
+
+ auto packs = doc.object().value("packs").toArray();
+ for (auto pack : packs) {
+ auto packId = pack.toInt();
+ remainingPacks.append(packId);
+ }
+
+ if (!remainingPacks.isEmpty()) {
+ currentPack = remainingPacks.at(0);
+ requestPack();
+ }
+ }
+
+ void ListModel::requestFailed(QString reason)
+ {
+ jobPtr.reset();
+ remainingPacks.clear();
+ }
+
+ void ListModel::requestPack()
+ {
+ auto* netJob = new NetJob("Ftb::Search", APPLICATION->network());
+ auto searchUrl =
+ QString(BuildConfig.MODPACKSCH_API_BASE_URL + "public/modpack/%1")
+ .arg(currentPack);
+ netJob->addNetAction(
+ Net::Download::makeByteArray(QUrl(searchUrl), &response));
+ jobPtr = netJob;
+ jobPtr->start();
+
+ QObject::connect(netJob, &NetJob::succeeded, this,
+ &ListModel::packRequestFinished);
+ QObject::connect(netJob, &NetJob::failed, this,
+ &ListModel::packRequestFailed);
+ }
+
+ void ListModel::packRequestFinished()
+ {
+ jobPtr.reset();
+ remainingPacks.removeOne(currentPack);
+
+ QJsonParseError parse_error;
+ QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error);
+
+ if (parse_error.error != QJsonParseError::NoError) {
+ qWarning() << "Error while parsing JSON response from FTB at "
+ << parse_error.offset
+ << " reason: " << parse_error.errorString();
+ qWarning() << response;
+ return;
+ }
+
+ auto obj = doc.object();
+
+ ModpacksCH::Modpack pack;
+ try {
+ ModpacksCH::loadModpack(pack, obj);
+ } catch (const JSONValidationError& e) {
+ qDebug() << QString::fromUtf8(response);
+ qWarning() << "Error while reading pack manifest from FTB: "
+ << e.cause();
+ return;
+ }
+
+ // Since there is no guarantee that packs have a version, this will just
+ // ignore those "dud" packs.
+ if (pack.versions.empty()) {
+ qWarning() << "FTB Pack " << pack.id
+ << " ignored. reason: lacking any versions";
+ } else {
+ beginInsertRows(QModelIndex(), modpacks.size(), modpacks.size());
+ modpacks.append(pack);
+ endInsertRows();
+ }
+
+ if (!remainingPacks.isEmpty()) {
+ currentPack = remainingPacks.at(0);
+ requestPack();
+ }
+ }
+
+ void ListModel::packRequestFailed(QString reason)
+ {
+ jobPtr.reset();
+ remainingPacks.removeOne(currentPack);
+ }
+
+ void ListModel::logoLoaded(QString logo, bool stale)
+ {
+ auto& logoObj = m_logoMap[logo];
+ logoObj.downloadJob.reset();
+ QString smallPath = logoObj.fullpath + ".small";
+
+ QFileInfo smallInfo(smallPath);
+
+ if (stale || !smallInfo.exists()) {
+ QImage image(logoObj.fullpath);
+ if (image.isNull()) {
+ logoObj.failed = true;
+ return;
+ }
+ QImage small;
+ if (image.width() > image.height()) {
+ small = image.scaledToWidth(512).scaledToWidth(
+ 256, Qt::SmoothTransformation);
+ } else {
+ small = image.scaledToHeight(512).scaledToHeight(
+ 256, Qt::SmoothTransformation);
+ }
+ QPoint offset((256 - small.width()) / 2,
+ (256 - small.height()) / 2);
+ QImage square(QSize(256, 256), QImage::Format_ARGB32);
+ square.fill(Qt::transparent);
+
+ QPainter painter(&square);
+ painter.drawImage(offset, small);
+ painter.end();
+
+ square.save(logoObj.fullpath + ".small", "PNG");
+ }
+
+ logoObj.result = QIcon(logoObj.fullpath + ".small");
+ for (int i = 0; i < modpacks.size(); i++) {
+ if (modpacks[i].name == logo) {
+ emit dataChanged(createIndex(i, 0), createIndex(i, 0),
+ {Qt::DecorationRole});
+ }
+ }
+ }
+
+ void ListModel::logoFailed(QString logo)
+ {
+ m_logoMap[logo].failed = true;
+ m_logoMap[logo].downloadJob.reset();
+ }
+
+ void ListModel::requestLogo(QString logo, QString url)
+ {
+ if (m_logoMap.contains(logo)) {
+ return;
+ }
+
+ MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry(
+ "ModpacksCHPacks",
+ QString("logos/%1").arg(logo.section(".", 0, 0)));
+
+ bool stale = entry->isStale();
+
+ NetJob* job = new NetJob(QString("FTB Icon Download %1").arg(logo),
+ APPLICATION->network());
+ job->addNetAction(Net::Download::makeCached(QUrl(url), entry));
+
+ auto fullPath = entry->getFullPath();
+ QObject::connect(
+ job, &NetJob::finished, this,
+ [this, logo, fullPath, stale] { logoLoaded(logo, stale); });
+
+ QObject::connect(job, &NetJob::failed, this,
+ [this, logo] { logoFailed(logo); });
+
+ auto& newLogoEntry = m_logoMap[logo];
+ newLogoEntry.downloadJob = job;
+ newLogoEntry.fullpath = fullPath;
+ job->start();
+ }
+
+} // namespace Ftb
diff --git a/meshmc/launcher/ui/pages/modplatform/ftb/FtbListModel.h b/meshmc/launcher/ui/pages/modplatform/ftb/FtbListModel.h
new file mode 100644
index 0000000000..63d5e9ecf5
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/ftb/FtbListModel.h
@@ -0,0 +1,101 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-2021 Jamie Mansfield <jmansfield@cadixdev.org>
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QAbstractListModel>
+
+#include "modplatform/modpacksch/FTBPackManifest.h"
+#include "net/NetJob.h"
+#include <QIcon>
+
+namespace Ftb
+{
+
+ struct Logo {
+ QString fullpath;
+ NetJob::Ptr downloadJob;
+ QIcon result;
+ bool failed = false;
+ };
+
+ typedef QMap<QString, Logo> LogoMap;
+ typedef std::function<void(QString)> LogoCallback;
+
+ class ListModel : public QAbstractListModel
+ {
+ Q_OBJECT
+
+ public:
+ ListModel(QObject* 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;
+
+ void request();
+
+ void getLogo(const QString& logo, const QString& logoUrl,
+ LogoCallback callback);
+
+ private slots:
+ void requestFinished();
+ void requestFailed(QString reason);
+
+ void requestPack();
+ void packRequestFinished();
+ void packRequestFailed(QString reason);
+
+ void logoFailed(QString logo);
+ void logoLoaded(QString logo, bool stale);
+
+ private:
+ void requestLogo(QString file, QString url);
+
+ private:
+ QList<ModpacksCH::Modpack> modpacks;
+ LogoMap m_logoMap;
+
+ NetJob::Ptr jobPtr;
+ int currentPack;
+ QList<int> remainingPacks;
+ QByteArray response;
+ };
+
+} // namespace Ftb
diff --git a/meshmc/launcher/ui/pages/modplatform/ftb/FtbPage.cpp b/meshmc/launcher/ui/pages/modplatform/ftb/FtbPage.cpp
new file mode 100644
index 0000000000..45064b1f0a
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/ftb/FtbPage.cpp
@@ -0,0 +1,193 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-2021 Jamie Mansfield <jmansfield@cadixdev.org>
+ * Copyright 2021 Philip T <me@phit.link>
+ *
+ * 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 "FtbPage.h"
+#include "ui_FtbPage.h"
+
+#include <QKeyEvent>
+
+#include "ui/dialogs/NewInstanceDialog.h"
+#include "modplatform/modpacksch/FTBPackInstallTask.h"
+
+#include "HoeDown.h"
+
+FtbPage::FtbPage(NewInstanceDialog* dialog, QWidget* parent)
+ : QWidget(parent), ui(new Ui::FtbPage), dialog(dialog)
+{
+ ui->setupUi(this);
+
+ filterModel = new Ftb::FilterModel(this);
+ listModel = new Ftb::ListModel(this);
+ filterModel->setSourceModel(listModel);
+ ui->packView->setModel(filterModel);
+ ui->packView->setSortingEnabled(true);
+ ui->packView->header()->hide();
+ ui->packView->setIndentation(0);
+
+ ui->searchEdit->installEventFilter(this);
+
+ ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(
+ Qt::ScrollBarAsNeeded);
+ ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300);
+
+ for (int i = 0; i < filterModel->getAvailableSortings().size(); i++) {
+ ui->sortByBox->addItem(
+ filterModel->getAvailableSortings().keys().at(i));
+ }
+ ui->sortByBox->setCurrentText(filterModel->translateCurrentSorting());
+
+ connect(ui->searchEdit, &QLineEdit::textChanged, this,
+ &FtbPage::triggerSearch);
+ connect(ui->sortByBox, &QComboBox::currentTextChanged, this,
+ &FtbPage::onSortingSelectionChanged);
+ connect(ui->packView->selectionModel(),
+ &QItemSelectionModel::currentChanged, this,
+ &FtbPage::onSelectionChanged);
+ connect(ui->versionSelectionBox, &QComboBox::currentTextChanged, this,
+ &FtbPage::onVersionSelectionChanged);
+}
+
+FtbPage::~FtbPage()
+{
+ delete ui;
+}
+
+bool FtbPage::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 FtbPage::shouldDisplay() const
+{
+ return true;
+}
+
+void FtbPage::openedImpl()
+{
+ if (!initialised) {
+ listModel->request();
+ initialised = true;
+ }
+
+ suggestCurrent();
+}
+
+void FtbPage::suggestCurrent()
+{
+ if (!isOpened) {
+ return;
+ }
+
+ if (selectedVersion.isEmpty()) {
+ dialog->setSuggestedPack();
+ return;
+ }
+
+ dialog->setSuggestedPack(
+ selected.name + " " + selectedVersion,
+ new ModpacksCH::PackInstallTask(selected, selectedVersion));
+ for (auto art : selected.art) {
+ if (art.type == "square") {
+ QString editedLogoName;
+ editedLogoName = selected.name;
+
+ listModel->getLogo(selected.name, art.url,
+ [this, editedLogoName](QString logo) {
+ dialog->setSuggestedIconFromFile(
+ logo + ".small", editedLogoName);
+ });
+ }
+ }
+}
+
+void FtbPage::triggerSearch()
+{
+ filterModel->setSearchTerm(ui->searchEdit->text());
+}
+
+void FtbPage::onSortingSelectionChanged(QString data)
+{
+ auto toSet = filterModel->getAvailableSortings().value(data);
+ filterModel->setSorting(toSet);
+}
+
+void FtbPage::onSelectionChanged(QModelIndex first, QModelIndex second)
+{
+ ui->versionSelectionBox->clear();
+
+ if (!first.isValid()) {
+ if (isOpened) {
+ dialog->setSuggestedPack();
+ }
+ return;
+ }
+
+ selected =
+ filterModel->data(first, Qt::UserRole).value<ModpacksCH::Modpack>();
+
+ HoeDown hoedown;
+ QString output = hoedown.process(selected.description.toUtf8());
+ ui->packDescription->setHtml(output);
+
+ // reverse foreach, so that the newest versions are first
+ for (auto i = selected.versions.size(); i--;) {
+ ui->versionSelectionBox->addItem(selected.versions.at(i).name);
+ }
+
+ suggestCurrent();
+}
+
+void FtbPage::onVersionSelectionChanged(QString data)
+{
+ if (data.isNull() || data.isEmpty()) {
+ selectedVersion = "";
+ return;
+ }
+
+ selectedVersion = data;
+ suggestCurrent();
+}
diff --git a/meshmc/launcher/ui/pages/modplatform/ftb/FtbPage.h b/meshmc/launcher/ui/pages/modplatform/ftb/FtbPage.h
new file mode 100644
index 0000000000..b32d17d013
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/ftb/FtbPage.h
@@ -0,0 +1,106 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include "FtbFilterModel.h"
+#include "FtbListModel.h"
+
+#include <QWidget>
+
+#include "Application.h"
+#include "ui/pages/BasePage.h"
+#include "tasks/Task.h"
+
+namespace Ui
+{
+ class FtbPage;
+}
+
+class NewInstanceDialog;
+
+class FtbPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit FtbPage(NewInstanceDialog* dialog, QWidget* parent = 0);
+ virtual ~FtbPage();
+ virtual QString displayName() const override
+ {
+ return tr("FTB");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("ftb_logo");
+ }
+ virtual QString id() const override
+ {
+ return "ftb";
+ }
+ virtual QString helpPage() const override
+ {
+ return "FTB-platform";
+ }
+ virtual bool shouldDisplay() const override;
+
+ void openedImpl() override;
+
+ bool eventFilter(QObject* watched, QEvent* event) override;
+
+ private:
+ void suggestCurrent();
+
+ private slots:
+ void triggerSearch();
+
+ void onSortingSelectionChanged(QString data);
+ void onSelectionChanged(QModelIndex first, QModelIndex second);
+ void onVersionSelectionChanged(QString data);
+
+ private:
+ Ui::FtbPage* ui = nullptr;
+ NewInstanceDialog* dialog = nullptr;
+ Ftb::ListModel* listModel = nullptr;
+ Ftb::FilterModel* filterModel = nullptr;
+
+ ModpacksCH::Modpack selected;
+ QString selectedVersion;
+
+ bool initialised{false};
+};
diff --git a/meshmc/launcher/ui/pages/modplatform/ftb/FtbPage.ui b/meshmc/launcher/ui/pages/modplatform/ftb/FtbPage.ui
new file mode 100644
index 0000000000..e9c783e358
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/ftb/FtbPage.ui
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>FtbPage</class>
+ <widget class="QWidget" name="FtbPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>875</width>
+ <height>745</height>
+ </rect>
+ </property>
+ <layout class="QGridLayout" name="gridLayout">
+ <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="0">
+ <widget class="QLineEdit" name="searchEdit">
+ <property name="placeholderText">
+ <string>Search and filter ...</string>
+ </property>
+ <property name="clearButtonEnabled">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0" colspan="2">
+ <layout class="QGridLayout" name="gridLayout_3">
+ <item row="0" column="0">
+ <widget class="QTreeView" name="packView">
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>48</width>
+ <height>48</height>
+ </size>
+ </property>
+ </widget>
+ </item>
+ <item row="0" 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>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>searchEdit</tabstop>
+ <tabstop>versionSelectionBox</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/modplatform/legacy_ftb/ListModel.cpp b/meshmc/launcher/ui/pages/modplatform/legacy_ftb/ListModel.cpp
new file mode 100644
index 0000000000..484e67e823
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/legacy_ftb/ListModel.cpp
@@ -0,0 +1,270 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ListModel.h"
+#include "Application.h"
+
+#include <MMCStrings.h>
+#include <Version.h>
+
+#include <QtMath>
+#include <QLabel>
+
+#include <RWStorage.h>
+
+#include <BuildConfig.h>
+
+namespace LegacyFTB
+{
+
+ FilterModel::FilterModel(QObject* parent) : QSortFilterProxyModel(parent)
+ {
+ currentSorting = Sorting::ByGameVersion;
+ sortings.insert(tr("Sort by name"), Sorting::ByName);
+ sortings.insert(tr("Sort by game version"), Sorting::ByGameVersion);
+ }
+
+ bool FilterModel::lessThan(const QModelIndex& left,
+ const QModelIndex& right) const
+ {
+ Modpack leftPack =
+ sourceModel()->data(left, Qt::UserRole).value<Modpack>();
+ Modpack rightPack =
+ sourceModel()->data(right, Qt::UserRole).value<Modpack>();
+
+ if (currentSorting == Sorting::ByGameVersion) {
+ Version lv(leftPack.mcVersion);
+ Version rv(rightPack.mcVersion);
+ return lv < rv;
+
+ } else if (currentSorting == Sorting::ByName) {
+ return Strings::naturalCompare(leftPack.name, rightPack.name,
+ Qt::CaseSensitive) >= 0;
+ }
+
+ // UHM, some inavlid value set?!
+ qWarning() << "Invalid sorting set!";
+ return true;
+ }
+
+ bool FilterModel::filterAcceptsRow(int sourceRow,
+ const QModelIndex& sourceParent) const
+ {
+ return true;
+ }
+
+ const QMap<QString, FilterModel::Sorting>
+ FilterModel::getAvailableSortings()
+ {
+ return sortings;
+ }
+
+ QString FilterModel::translateCurrentSorting()
+ {
+ return sortings.key(currentSorting);
+ }
+
+ void FilterModel::setSorting(Sorting s)
+ {
+ currentSorting = s;
+ invalidate();
+ }
+
+ FilterModel::Sorting FilterModel::getCurrentSorting()
+ {
+ return currentSorting;
+ }
+
+ ListModel::ListModel(QObject* parent) : QAbstractListModel(parent) {}
+
+ ListModel::~ListModel() {}
+
+ QString ListModel::translatePackType(PackType type) const
+ {
+ switch (type) {
+ case PackType::Public:
+ return tr("Public Modpack");
+ case PackType::ThirdParty:
+ return tr("Third Party Modpack");
+ case PackType::Private:
+ return tr("Private Modpack");
+ }
+ qWarning() << "Unknown FTB modpack type:" << int(type);
+ return QString();
+ }
+
+ 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);
+ }
+
+ Modpack pack = modpacks.at(pos);
+ if (role == Qt::DisplayRole) {
+ return pack.name + "\n" + translatePackType(pack.type);
+ } 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.logo)) {
+ return (m_logoMap.value(pack.logo));
+ }
+ QIcon icon = APPLICATION->getThemedIcon("screenshot-placeholder");
+ ((ListModel*)this)->requestLogo(pack.logo);
+ return icon;
+ } else if (role == Qt::ForegroundRole) {
+ if (pack.broken) {
+ // FIXME: Hardcoded color
+ return QColor(255, 0, 50);
+ } else if (pack.bugged) {
+ // FIXME: Hardcoded color
+ // bugged pack, currently only indicates bugged xml
+ return QColor(244, 229, 66);
+ }
+ } else if (role == Qt::UserRole) {
+ QVariant v;
+ v.setValue(pack);
+ return v;
+ }
+
+ return QVariant();
+ }
+
+ void ListModel::fill(ModpackList modpacks)
+ {
+ beginResetModel();
+ this->modpacks = modpacks;
+ endResetModel();
+ }
+
+ void ListModel::addPack(Modpack modpack)
+ {
+ beginResetModel();
+ this->modpacks.append(modpack);
+ endResetModel();
+ }
+
+ void ListModel::clear()
+ {
+ beginResetModel();
+ modpacks.clear();
+ endResetModel();
+ }
+
+ Modpack ListModel::at(int row)
+ {
+ return modpacks.at(row);
+ }
+
+ void ListModel::remove(int row)
+ {
+ if (row < 0 || row >= modpacks.size()) {
+ qWarning() << "Attempt to remove FTB modpacks with invalid row"
+ << row;
+ return;
+ }
+ beginRemoveRows(QModelIndex(), row, row);
+ modpacks.removeAt(row);
+ endRemoveRows();
+ }
+
+ void ListModel::logoLoaded(QString logo, QIcon out)
+ {
+ m_loadingLogos.removeAll(logo);
+ m_logoMap.insert(logo, out);
+ emit dataChanged(createIndex(0, 0), createIndex(1, 0));
+ }
+
+ void ListModel::logoFailed(QString logo)
+ {
+ m_failedLogos.append(logo);
+ m_loadingLogos.removeAll(logo);
+ }
+
+ void ListModel::requestLogo(QString file)
+ {
+ if (m_loadingLogos.contains(file) || m_failedLogos.contains(file)) {
+ return;
+ }
+
+ MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry(
+ "FTBPacks", QString("logos/%1").arg(file.section(".", 0, 0)));
+ NetJob* job = new NetJob(QString("FTB Icon Download for %1").arg(file),
+ APPLICATION->network());
+ job->addNetAction(Net::Download::makeCached(
+ QUrl(QString(BuildConfig.LEGACY_FTB_CDN_BASE_URL + "static/%1")
+ .arg(file)),
+ entry));
+
+ auto fullPath = entry->getFullPath();
+ QObject::connect(job, &NetJob::finished, this, [this, file, fullPath] {
+ emit logoLoaded(file, QIcon(fullPath));
+ if (waitingCallbacks.contains(file)) {
+ waitingCallbacks.value(file)(fullPath);
+ }
+ });
+
+ QObject::connect(job, &NetJob::failed, this,
+ [this, file] { emit logoFailed(file); });
+
+ job->start();
+
+ m_loadingLogos.append(file);
+ }
+
+ void ListModel::getLogo(const QString& logo, LogoCallback callback)
+ {
+ if (m_logoMap.contains(logo)) {
+ callback(APPLICATION->metacache()
+ ->resolveEntry(
+ "FTBPacks",
+ QString("logos/%1").arg(logo.section(".", 0, 0)))
+ ->getFullPath());
+ } else {
+ requestLogo(logo);
+ }
+ }
+
+ Qt::ItemFlags ListModel::flags(const QModelIndex& index) const
+ {
+ return QAbstractListModel::flags(index);
+ }
+
+} // namespace LegacyFTB
diff --git a/meshmc/launcher/ui/pages/modplatform/legacy_ftb/ListModel.h b/meshmc/launcher/ui/pages/modplatform/legacy_ftb/ListModel.h
new file mode 100644
index 0000000000..72c2d99fe6
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/legacy_ftb/ListModel.h
@@ -0,0 +1,97 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <modplatform/legacy_ftb/PackHelpers.h>
+#include <RWStorage.h>
+
+#include <QAbstractListModel>
+#include <QSortFilterProxyModel>
+#include <QThreadPool>
+#include <QIcon>
+#include <QStyledItemDelegate>
+
+#include <functional>
+
+namespace LegacyFTB
+{
+
+ typedef QMap<QString, QIcon> FTBLogoMap;
+ typedef std::function<void(QString)> LogoCallback;
+
+ class FilterModel : public QSortFilterProxyModel
+ {
+ Q_OBJECT
+ public:
+ FilterModel(QObject* parent = Q_NULLPTR);
+ enum Sorting { ByName, ByGameVersion };
+ const QMap<QString, Sorting> getAvailableSortings();
+ QString translateCurrentSorting();
+ void setSorting(Sorting sorting);
+ Sorting getCurrentSorting();
+
+ protected:
+ bool filterAcceptsRow(int sourceRow,
+ const QModelIndex& sourceParent) const override;
+ bool lessThan(const QModelIndex& left,
+ const QModelIndex& right) const override;
+
+ private:
+ QMap<QString, Sorting> sortings;
+ Sorting currentSorting;
+ };
+
+ class ListModel : public QAbstractListModel
+ {
+ Q_OBJECT
+ private:
+ ModpackList modpacks;
+ QStringList m_failedLogos;
+ QStringList m_loadingLogos;
+ FTBLogoMap m_logoMap;
+ QMap<QString, LogoCallback> waitingCallbacks;
+
+ void requestLogo(QString file);
+ QString translatePackType(PackType type) const;
+
+ private slots:
+ void logoFailed(QString logo);
+ void logoLoaded(QString logo, QIcon out);
+
+ public:
+ ListModel(QObject* parent);
+ ~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;
+
+ void fill(ModpackList modpacks);
+ void addPack(Modpack modpack);
+ void clear();
+ void remove(int row);
+
+ Modpack at(int row);
+ void getLogo(const QString& logo, LogoCallback callback);
+ };
+
+} // namespace LegacyFTB
diff --git a/meshmc/launcher/ui/pages/modplatform/legacy_ftb/Page.cpp b/meshmc/launcher/ui/pages/modplatform/legacy_ftb/Page.cpp
new file mode 100644
index 0000000000..6496888fc9
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/legacy_ftb/Page.cpp
@@ -0,0 +1,387 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "Page.h"
+#include "ui_Page.h"
+
+#include <QInputDialog>
+
+#include "Application.h"
+
+#include "ui/dialogs/CustomMessageBox.h"
+#include "ui/dialogs/NewInstanceDialog.h"
+
+#include "modplatform/legacy_ftb/PackFetchTask.h"
+#include "modplatform/legacy_ftb/PackInstallTask.h"
+#include "modplatform/legacy_ftb/PrivatePackManager.h"
+#include "ListModel.h"
+
+namespace LegacyFTB
+{
+
+ Page::Page(NewInstanceDialog* dialog, QWidget* parent)
+ : QWidget(parent), dialog(dialog), ui(new Ui::Page)
+ {
+ ftbFetchTask.reset(new PackFetchTask(APPLICATION->network()));
+ ftbPrivatePacks.reset(new PrivatePackManager());
+
+ ui->setupUi(this);
+
+ {
+ publicFilterModel = new FilterModel(this);
+ publicListModel = new ListModel(this);
+ publicFilterModel->setSourceModel(publicListModel);
+
+ ui->publicPackList->setModel(publicFilterModel);
+ ui->publicPackList->setSortingEnabled(true);
+ ui->publicPackList->header()->hide();
+ ui->publicPackList->setIndentation(0);
+ ui->publicPackList->setIconSize(QSize(42, 42));
+
+ for (int i = 0;
+ i < publicFilterModel->getAvailableSortings().size(); i++) {
+ ui->sortByBox->addItem(
+ publicFilterModel->getAvailableSortings().keys().at(i));
+ }
+
+ ui->sortByBox->setCurrentText(
+ publicFilterModel->translateCurrentSorting());
+ }
+
+ {
+ thirdPartyFilterModel = new FilterModel(this);
+ thirdPartyModel = new ListModel(this);
+ thirdPartyFilterModel->setSourceModel(thirdPartyModel);
+
+ ui->thirdPartyPackList->setModel(thirdPartyFilterModel);
+ ui->thirdPartyPackList->setSortingEnabled(true);
+ ui->thirdPartyPackList->header()->hide();
+ ui->thirdPartyPackList->setIndentation(0);
+ ui->thirdPartyPackList->setIconSize(QSize(42, 42));
+
+ thirdPartyFilterModel->setSorting(
+ publicFilterModel->getCurrentSorting());
+ }
+
+ {
+ privateFilterModel = new FilterModel(this);
+ privateListModel = new ListModel(this);
+ privateFilterModel->setSourceModel(privateListModel);
+
+ ui->privatePackList->setModel(privateFilterModel);
+ ui->privatePackList->setSortingEnabled(true);
+ ui->privatePackList->header()->hide();
+ ui->privatePackList->setIndentation(0);
+ ui->privatePackList->setIconSize(QSize(42, 42));
+
+ privateFilterModel->setSorting(
+ publicFilterModel->getCurrentSorting());
+ }
+
+ ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(
+ Qt::ScrollBarAsNeeded);
+ ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300);
+
+ connect(ui->sortByBox, &QComboBox::currentTextChanged, this,
+ &Page::onSortingSelectionChanged);
+ connect(ui->versionSelectionBox, &QComboBox::currentTextChanged, this,
+ &Page::onVersionSelectionItemChanged);
+
+ connect(ui->publicPackList->selectionModel(),
+ &QItemSelectionModel::currentChanged, this,
+ &Page::onPublicPackSelectionChanged);
+ connect(ui->thirdPartyPackList->selectionModel(),
+ &QItemSelectionModel::currentChanged, this,
+ &Page::onThirdPartyPackSelectionChanged);
+ connect(ui->privatePackList->selectionModel(),
+ &QItemSelectionModel::currentChanged, this,
+ &Page::onPrivatePackSelectionChanged);
+
+ connect(ui->addPackBtn, &QPushButton::pressed, this,
+ &Page::onAddPackClicked);
+ connect(ui->removePackBtn, &QPushButton::pressed, this,
+ &Page::onRemovePackClicked);
+
+ connect(ui->tabWidget, &QTabWidget::currentChanged, this,
+ &Page::onTabChanged);
+
+ // ui->modpackInfo->setOpenExternalLinks(true);
+
+ ui->publicPackList->selectionModel()->reset();
+ ui->thirdPartyPackList->selectionModel()->reset();
+ ui->privatePackList->selectionModel()->reset();
+
+ onTabChanged(ui->tabWidget->currentIndex());
+ }
+
+ Page::~Page()
+ {
+ delete ui;
+ }
+
+ bool Page::shouldDisplay() const
+ {
+ return true;
+ }
+
+ void Page::openedImpl()
+ {
+ if (!initialized) {
+ connect(ftbFetchTask.get(), &PackFetchTask::finished, this,
+ &Page::ftbPackDataDownloadSuccessfully);
+ connect(ftbFetchTask.get(), &PackFetchTask::failed, this,
+ &Page::ftbPackDataDownloadFailed);
+
+ connect(ftbFetchTask.get(),
+ &PackFetchTask::privateFileDownloadFinished, this,
+ &Page::ftbPrivatePackDataDownloadSuccessfully);
+ connect(ftbFetchTask.get(),
+ &PackFetchTask::privateFileDownloadFailed, this,
+ &Page::ftbPrivatePackDataDownloadFailed);
+
+ ftbFetchTask->fetch();
+ ftbPrivatePacks->load();
+ ftbFetchTask->fetchPrivate(
+ ftbPrivatePacks->getCurrentPackCodes().values());
+ initialized = true;
+ }
+ suggestCurrent();
+ }
+
+ void Page::suggestCurrent()
+ {
+ if (!isOpened) {
+ return;
+ }
+
+ if (selected.broken || selectedVersion.isEmpty()) {
+ dialog->setSuggestedPack();
+ return;
+ }
+
+ dialog->setSuggestedPack(
+ selected.name, new PackInstallTask(APPLICATION->network(), selected,
+ selectedVersion));
+ QString editedLogoName;
+ if (selected.logo.toLower().startsWith("ftb")) {
+ editedLogoName = selected.logo;
+ } else {
+ editedLogoName = "ftb_" + selected.logo;
+ }
+
+ editedLogoName =
+ editedLogoName.left(editedLogoName.lastIndexOf(".png"));
+
+ if (selected.type == PackType::Public) {
+ publicListModel->getLogo(
+ selected.logo, [this, editedLogoName](QString logo) {
+ dialog->setSuggestedIconFromFile(logo, editedLogoName);
+ });
+ } else if (selected.type == PackType::ThirdParty) {
+ thirdPartyModel->getLogo(
+ selected.logo, [this, editedLogoName](QString logo) {
+ dialog->setSuggestedIconFromFile(logo, editedLogoName);
+ });
+ } else if (selected.type == PackType::Private) {
+ privateListModel->getLogo(
+ selected.logo, [this, editedLogoName](QString logo) {
+ dialog->setSuggestedIconFromFile(logo, editedLogoName);
+ });
+ }
+ }
+
+ void Page::ftbPackDataDownloadSuccessfully(ModpackList publicPacks,
+ ModpackList thirdPartyPacks)
+ {
+ publicListModel->fill(publicPacks);
+ thirdPartyModel->fill(thirdPartyPacks);
+ }
+
+ void Page::ftbPackDataDownloadFailed(QString reason)
+ {
+ // TODO: Display the error
+ }
+
+ void Page::ftbPrivatePackDataDownloadSuccessfully(Modpack pack)
+ {
+ privateListModel->addPack(pack);
+ }
+
+ void Page::ftbPrivatePackDataDownloadFailed(QString reason,
+ QString packCode)
+ {
+ auto reply =
+ QMessageBox::question(this, tr("FTB private packs"),
+ tr("Failed to download pack information for "
+ "code %1.\nShould it be removed now?")
+ .arg(packCode));
+ if (reply == QMessageBox::Yes) {
+ ftbPrivatePacks->remove(packCode);
+ }
+ }
+
+ void Page::onPublicPackSelectionChanged(QModelIndex now, QModelIndex prev)
+ {
+ if (!now.isValid()) {
+ onPackSelectionChanged();
+ return;
+ }
+ Modpack selectedPack =
+ publicFilterModel->data(now, Qt::UserRole).value<Modpack>();
+ onPackSelectionChanged(&selectedPack);
+ }
+
+ void Page::onThirdPartyPackSelectionChanged(QModelIndex now,
+ QModelIndex prev)
+ {
+ if (!now.isValid()) {
+ onPackSelectionChanged();
+ return;
+ }
+ Modpack selectedPack =
+ thirdPartyFilterModel->data(now, Qt::UserRole).value<Modpack>();
+ onPackSelectionChanged(&selectedPack);
+ }
+
+ void Page::onPrivatePackSelectionChanged(QModelIndex now, QModelIndex prev)
+ {
+ if (!now.isValid()) {
+ onPackSelectionChanged();
+ return;
+ }
+ Modpack selectedPack =
+ privateFilterModel->data(now, Qt::UserRole).value<Modpack>();
+ onPackSelectionChanged(&selectedPack);
+ }
+
+ void Page::onPackSelectionChanged(Modpack* pack)
+ {
+ ui->versionSelectionBox->clear();
+ if (pack) {
+ currentModpackInfo->setHtml(
+ "Pack by <b>" + pack->author + "</b>" + "<br>Minecraft " +
+ pack->mcVersion + "<br>" + "<br>" + pack->description +
+ "<ul><li>" + pack->mods.replace(";", "</li><li>") +
+ "</li></ul>");
+ bool currentAdded = false;
+
+ for (int i = 0; i < pack->oldVersions.size(); i++) {
+ if (pack->currentVersion == pack->oldVersions.at(i)) {
+ currentAdded = true;
+ }
+ ui->versionSelectionBox->addItem(pack->oldVersions.at(i));
+ }
+
+ if (!currentAdded) {
+ ui->versionSelectionBox->addItem(pack->currentVersion);
+ }
+ selected = *pack;
+ } else {
+ currentModpackInfo->setHtml("");
+ ui->versionSelectionBox->clear();
+ if (isOpened) {
+ dialog->setSuggestedPack();
+ }
+ return;
+ }
+ suggestCurrent();
+ }
+
+ void Page::onVersionSelectionItemChanged(QString data)
+ {
+ if (data.isNull() || data.isEmpty()) {
+ selectedVersion = "";
+ return;
+ }
+
+ selectedVersion = data;
+ suggestCurrent();
+ }
+
+ void Page::onSortingSelectionChanged(QString data)
+ {
+ FilterModel::Sorting toSet =
+ publicFilterModel->getAvailableSortings().value(data);
+ publicFilterModel->setSorting(toSet);
+ thirdPartyFilterModel->setSorting(toSet);
+ privateFilterModel->setSorting(toSet);
+ }
+
+ void Page::onTabChanged(int tab)
+ {
+ if (tab == 1) {
+ currentModel = thirdPartyFilterModel;
+ currentList = ui->thirdPartyPackList;
+ currentModpackInfo = ui->thirdPartyPackDescription;
+ } else if (tab == 2) {
+ currentModel = privateFilterModel;
+ currentList = ui->privatePackList;
+ currentModpackInfo = ui->privatePackDescription;
+ } else {
+ currentModel = publicFilterModel;
+ currentList = ui->publicPackList;
+ currentModpackInfo = ui->publicPackDescription;
+ }
+
+ currentList->selectionModel()->reset();
+ QModelIndex idx = currentList->currentIndex();
+ if (idx.isValid()) {
+ auto pack = currentModel->data(idx, Qt::UserRole).value<Modpack>();
+ onPackSelectionChanged(&pack);
+ } else {
+ onPackSelectionChanged();
+ }
+ }
+
+ void Page::onAddPackClicked()
+ {
+ bool ok;
+ QString text = QInputDialog::getText(this, tr("Add FTB pack"),
+ tr("Enter pack code:"),
+ QLineEdit::Normal, QString(), &ok);
+ if (ok && !text.isEmpty()) {
+ ftbPrivatePacks->add(text);
+ ftbFetchTask->fetchPrivate({text});
+ }
+ }
+
+ void Page::onRemovePackClicked()
+ {
+ auto index = ui->privatePackList->currentIndex();
+ if (!index.isValid()) {
+ return;
+ }
+ auto row = index.row();
+ Modpack pack = privateListModel->at(row);
+ auto answer = QMessageBox::question(
+ this, tr("Remove pack"),
+ tr("Are you sure you want to remove pack %1?").arg(pack.name),
+ QMessageBox::Yes | QMessageBox::No);
+ if (answer != QMessageBox::Yes) {
+ return;
+ }
+
+ ftbPrivatePacks->remove(pack.packCode);
+ privateListModel->remove(row);
+ onPackSelectionChanged();
+ }
+
+} // namespace LegacyFTB
diff --git a/meshmc/launcher/ui/pages/modplatform/legacy_ftb/Page.h b/meshmc/launcher/ui/pages/modplatform/legacy_ftb/Page.h
new file mode 100644
index 0000000000..7782e19661
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/legacy_ftb/Page.h
@@ -0,0 +1,147 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+#include <QTreeView>
+#include <QTextBrowser>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include "tasks/Task.h"
+#include "modplatform/legacy_ftb/PackHelpers.h"
+#include "modplatform/legacy_ftb/PackFetchTask.h"
+#include "QObjectPtr.h"
+
+class NewInstanceDialog;
+
+namespace LegacyFTB
+{
+
+ namespace Ui
+ {
+ class Page;
+ }
+
+ class ListModel;
+ class FilterModel;
+ class PrivatePackListModel;
+ class PrivatePackFilterModel;
+ class PrivatePackManager;
+
+ class Page : public QWidget, public BasePage
+ {
+ Q_OBJECT
+
+ public:
+ explicit Page(NewInstanceDialog* dialog, QWidget* parent = 0);
+ virtual ~Page();
+ QString displayName() const override
+ {
+ return tr("FTB Legacy");
+ }
+ QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("ftb_logo");
+ }
+ QString id() const override
+ {
+ return "legacy_ftb";
+ }
+ QString helpPage() const override
+ {
+ return "FTB-platform";
+ }
+ bool shouldDisplay() const override;
+ void openedImpl() override;
+
+ private:
+ void suggestCurrent();
+ void onPackSelectionChanged(Modpack* pack = nullptr);
+
+ private slots:
+ void ftbPackDataDownloadSuccessfully(ModpackList publicPacks,
+ ModpackList thirdPartyPacks);
+ void ftbPackDataDownloadFailed(QString reason);
+
+ void ftbPrivatePackDataDownloadSuccessfully(Modpack pack);
+ void ftbPrivatePackDataDownloadFailed(QString reason, QString packCode);
+
+ void onSortingSelectionChanged(QString data);
+ void onVersionSelectionItemChanged(QString data);
+
+ void onPublicPackSelectionChanged(QModelIndex first,
+ QModelIndex second);
+ void onThirdPartyPackSelectionChanged(QModelIndex first,
+ QModelIndex second);
+ void onPrivatePackSelectionChanged(QModelIndex first,
+ QModelIndex second);
+
+ void onTabChanged(int tab);
+
+ void onAddPackClicked();
+ void onRemovePackClicked();
+
+ private:
+ FilterModel* currentModel = nullptr;
+ QTreeView* currentList = nullptr;
+ QTextBrowser* currentModpackInfo = nullptr;
+
+ bool initialized = false;
+ Modpack selected;
+ QString selectedVersion;
+
+ ListModel* publicListModel = nullptr;
+ FilterModel* publicFilterModel = nullptr;
+
+ ListModel* thirdPartyModel = nullptr;
+ FilterModel* thirdPartyFilterModel = nullptr;
+
+ ListModel* privateListModel = nullptr;
+ FilterModel* privateFilterModel = nullptr;
+
+ unique_qobject_ptr<PackFetchTask> ftbFetchTask;
+ std::unique_ptr<PrivatePackManager> ftbPrivatePacks;
+
+ NewInstanceDialog* dialog = nullptr;
+
+ Ui::Page* ui = nullptr;
+ };
+
+} // namespace LegacyFTB
diff --git a/meshmc/launcher/ui/pages/modplatform/legacy_ftb/Page.ui b/meshmc/launcher/ui/pages/modplatform/legacy_ftb/Page.ui
new file mode 100644
index 0000000000..15e5d4325c
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/legacy_ftb/Page.ui
@@ -0,0 +1,135 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>LegacyFTB::Page</class>
+ <widget class="QWidget" name="LegacyFTB::Page">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>709</width>
+ <height>602</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QTabWidget" name="tabWidget">
+ <property name="currentIndex">
+ <number>0</number>
+ </property>
+ <widget class="QWidget" name="tab">
+ <attribute name="title">
+ <string>Public</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="0" column="0">
+ <widget class="QTreeView" name="publicPackList">
+ <property name="maximumSize">
+ <size>
+ <width>250</width>
+ <height>16777215</height>
+ </size>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QTextBrowser" name="publicPackDescription"/>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="tab_2">
+ <attribute name="title">
+ <string>3rd Party</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout_3">
+ <item row="0" column="1">
+ <widget class="QTextBrowser" name="thirdPartyPackDescription"/>
+ </item>
+ <item row="0" column="0">
+ <widget class="QTreeView" name="thirdPartyPackList">
+ <property name="maximumSize">
+ <size>
+ <width>250</width>
+ <height>16777215</height>
+ </size>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="tab_3">
+ <attribute name="title">
+ <string>Private</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="0">
+ <widget class="QTreeView" name="privatePackList">
+ <property name="maximumSize">
+ <size>
+ <width>250</width>
+ <height>16777215</height>
+ </size>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QPushButton" name="addPackBtn">
+ <property name="text">
+ <string>Add pack</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
+ <widget class="QPushButton" name="removePackBtn">
+ <property name="text">
+ <string>Remove selected pack</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1" rowspan="3">
+ <widget class="QTextBrowser" name="privatePackDescription"/>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ <item>
+ <layout class="QGridLayout" name="gridLayout_4">
+ <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="2">
+ <widget class="QComboBox" name="versionSelectionBox"/>
+ </item>
+ <item row="0" column="0">
+ <widget class="QComboBox" name="sortByBox">
+ <property name="minimumSize">
+ <size>
+ <width>265</width>
+ <height>0</height>
+ </size>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp b/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp
new file mode 100644
index 0000000000..1f965a03d2
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp
@@ -0,0 +1,273 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ModrinthModel.h"
+
+#include "Application.h"
+#include "Json.h"
+
+#include <QtMath>
+
+namespace Modrinth
+{
+
+ ListModel::ListModel(QObject* 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) {
+ QString edit = pack.description.left(97);
+ edit = edit.left(edit.lastIndexOf(" ")).append("...");
+ return edit;
+ }
+ return pack.description;
+ } else if (role == Qt::DecorationRole) {
+ if (m_logoMap.contains(pack.slug)) {
+ return (m_logoMap.value(pack.slug));
+ }
+ QIcon icon = APPLICATION->getThemedIcon("screenshot-placeholder");
+ ((ListModel*)this)->requestLogo(pack.slug, pack.iconUrl);
+ 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].slug == 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));
+ NetJob* 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, job, logo, fullPath] {
+ job->deleteLater();
+ emit logoLoaded(logo, QIcon(fullPath));
+ if (waitingCallbacks.contains(logo)) {
+ waitingCallbacks.value(logo)(fullPath);
+ }
+ });
+
+ QObject::connect(job, &NetJob::failed, this, [this, job, logo] {
+ 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))
+ ->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();
+ }
+
+ void ListModel::performPaginatedSearch()
+ {
+ static const char* sortFields[] = {"relevance", "downloads", "updated",
+ "newest", "follows"};
+ int sortIndex = (currentSort >= 0 && currentSort < 5) ? currentSort : 0;
+
+ NetJob* netJob = new NetJob("Modrinth::Search", APPLICATION->network());
+ auto searchUrl = QString("https://api.modrinth.com/v2/search?"
+ "query=%1&"
+ "facets=[[\"project_type:modpack\"]]&"
+ "index=%2&"
+ "offset=%3&"
+ "limit=20")
+ .arg(currentSearchTerm, sortFields[sortIndex])
+ .arg(nextSearchOffset);
+ 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, 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 Modrinth at "
+ << parse_error.offset
+ << " reason: " << parse_error.errorString();
+ qWarning() << response;
+ return;
+ }
+
+ QList<Modrinth::IndexedPack> newList;
+ auto obj = doc.object();
+ auto hits = Json::ensureArray(obj, "hits");
+ for (auto packRaw : hits) {
+ auto packObj = packRaw.toObject();
+ Modrinth::IndexedPack pack;
+ try {
+ Modrinth::loadIndexedPack(pack, packObj);
+ newList.append(pack);
+ } catch (const JSONValidationError& e) {
+ qWarning() << "Error while loading pack from Modrinth: "
+ << e.cause();
+ continue;
+ }
+ }
+
+ int totalHits = Json::ensureInteger(obj, "total_hits", 0);
+ if (newList.size() < 20 ||
+ (nextSearchOffset + newList.size()) >= totalHits) {
+ searchState = Finished;
+ } else {
+ nextSearchOffset += 20;
+ 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;
+ }
+ }
+
+} // namespace Modrinth
diff --git a/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h b/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h
new file mode 100644
index 0000000000..dcec31e0af
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h
@@ -0,0 +1,95 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <RWStorage.h>
+
+#include <QAbstractListModel>
+#include <QIcon>
+#include <QList>
+#include <QMetaType>
+#include <QString>
+#include <QStringList>
+
+#include <functional>
+#include <net/NetJob.h>
+
+#include <modplatform/modrinth/ModrinthPackIndex.h>
+
+namespace Modrinth
+{
+
+ typedef QMap<QString, QIcon> LogoMap;
+ typedef std::function<void(QString)> LogoCallback;
+
+ class ListModel : public QAbstractListModel
+ {
+ Q_OBJECT
+
+ public:
+ explicit ListModel(QObject* parent);
+ virtual ~ListModel() override;
+
+ 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;
+ };
+
+} // namespace Modrinth
diff --git a/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp b/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp
new file mode 100644
index 0000000000..dbcae494ca
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp
@@ -0,0 +1,232 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ModrinthPage.h"
+#include "ui_ModrinthPage.h"
+
+#include <QKeyEvent>
+
+#include "Application.h"
+#include "Json.h"
+#include "ui/dialogs/NewInstanceDialog.h"
+#include "InstanceImportTask.h"
+#include "ModrinthModel.h"
+
+ModrinthPage::ModrinthPage(NewInstanceDialog* dialog, QWidget* parent)
+ : QWidget(parent), 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);
+
+ ui->sortByBox->addItem(tr("Sort by relevance"));
+ ui->sortByBox->addItem(tr("Sort by downloads"));
+ ui->sortByBox->addItem(tr("Sort by last updated"));
+ ui->sortByBox->addItem(tr("Sort by newest"));
+ ui->sortByBox->addItem(tr("Sort by follows"));
+
+ connect(ui->sortByBox, QOverload<int>::of(&QComboBox::currentIndexChanged),
+ this, &ModrinthPage::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->setSuggestedPack();
+ }
+ return;
+ }
+
+ current =
+ listModel->data(first, Qt::UserRole).value<Modrinth::IndexedPack>();
+ QString text = "";
+ QString name = current.name;
+
+ if (current.slug.isEmpty()) {
+ text = name;
+ } else {
+ text = "<a href=\"https://modrinth.com/modpack/" + current.slug +
+ "\">" + name + "</a>";
+ }
+
+ if (!current.author.isEmpty()) {
+ text += "<br>" + tr(" by ") + current.author;
+ }
+ text += "<br><br>";
+
+ ui->packDescription->setHtml(text + current.description);
+
+ if (isOpened) {
+ dialog->setSuggestedPack(current.name);
+ }
+
+ if (!current.versionsLoaded) {
+ qDebug() << "Loading Modrinth modpack versions";
+ NetJob* netJob =
+ new NetJob(QString("Modrinth::PackVersions(%1)").arg(current.name),
+ APPLICATION->network());
+ std::shared_ptr<QByteArray> versionResponse =
+ std::make_shared<QByteArray>();
+ QString projectId = current.projectId;
+ netJob->addNetAction(Net::Download::makeByteArray(
+ QString("https://api.modrinth.com/v2/project/%1/version?"
+ "loaders=[\"forge\",\"fabric\",\"quilt\",\"neoforge\"]")
+ .arg(projectId),
+ versionResponse.get()));
+
+ QObject::connect(
+ netJob, &NetJob::succeeded, this, [this, netJob, versionResponse] {
+ netJob->deleteLater();
+ QJsonParseError parse_error;
+ QJsonDocument doc =
+ QJsonDocument::fromJson(*versionResponse, &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() << *versionResponse;
+ return;
+ }
+ QJsonArray arr = doc.array();
+ try {
+ Modrinth::loadIndexedPackVersions(current, arr);
+ } catch (const JSONValidationError& e) {
+ qDebug() << *versionResponse;
+ qWarning()
+ << "Error while reading Modrinth modpack version: "
+ << e.cause();
+ }
+
+ for (auto version : current.versions) {
+ QString label = version.versionNumber;
+ if (!version.mcVersion.isEmpty()) {
+ label += " [" + version.mcVersion + "]";
+ }
+ if (!version.loaders.isEmpty()) {
+ label += " (" + version.loaders + ")";
+ }
+ ui->versionSelectionBox->addItem(
+ label, QVariant(version.downloadUrl));
+ }
+
+ suggestCurrent();
+ });
+ QObject::connect(netJob, &NetJob::failed, this,
+ [netJob] { netJob->deleteLater(); });
+ netJob->start();
+ } else {
+ for (auto version : current.versions) {
+ QString label = version.versionNumber;
+ if (!version.mcVersion.isEmpty()) {
+ label += " [" + version.mcVersion + "]";
+ }
+ if (!version.loaders.isEmpty()) {
+ label += " (" + version.loaders + ")";
+ }
+ ui->versionSelectionBox->addItem(label,
+ QVariant(version.downloadUrl));
+ }
+
+ suggestCurrent();
+ }
+}
+
+void ModrinthPage::suggestCurrent()
+{
+ if (!isOpened) {
+ return;
+ }
+
+ if (selectedVersion.isEmpty()) {
+ dialog->setSuggestedPack();
+ return;
+ }
+
+ dialog->setSuggestedPack(current.name,
+ new InstanceImportTask(selectedVersion));
+ QString editedLogoName;
+ editedLogoName = "modrinth_" + current.slug;
+ listModel->getLogo(
+ current.slug, current.iconUrl, [this, editedLogoName](QString logo) {
+ dialog->setSuggestedIconFromFile(logo, editedLogoName);
+ });
+}
+
+void ModrinthPage::onVersionSelectionChanged(QString data)
+{
+ if (data.isNull() || data.isEmpty()) {
+ selectedVersion = "";
+ return;
+ }
+ selectedVersion = ui->versionSelectionBox->currentData().toString();
+ suggestCurrent();
+}
diff --git a/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h b/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h
new file mode 100644
index 0000000000..9983feccbf
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h
@@ -0,0 +1,87 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#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 NewInstanceDialog;
+
+namespace Modrinth
+{
+ class ListModel;
+}
+
+class ModrinthPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit ModrinthPage(NewInstanceDialog* dialog, QWidget* parent = 0);
+ virtual ~ModrinthPage() override;
+ 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;
+
+ private:
+ void suggestCurrent();
+
+ private slots:
+ void triggerSearch();
+ void onSelectionChanged(QModelIndex first, QModelIndex second);
+ void onVersionSelectionChanged(QString data);
+
+ private:
+ Ui::ModrinthPage* ui = nullptr;
+ NewInstanceDialog* dialog = nullptr;
+ Modrinth::ListModel* listModel = nullptr;
+ Modrinth::IndexedPack current;
+
+ QString selectedVersion;
+};
diff --git a/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthPage.ui b/meshmc/launcher/ui/pages/modplatform/modrinth/ModrinthPage.ui
new file mode 100644
index 0000000000..6d183de50c
--- /dev/null
+++ b/meshmc/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/meshmc/launcher/ui/pages/modplatform/technic/TechnicData.h b/meshmc/launcher/ui/pages/modplatform/technic/TechnicData.h
new file mode 100644
index 0000000000..029a842662
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/technic/TechnicData.h
@@ -0,0 +1,66 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-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.
+ */
+
+#pragma once
+
+#include <QList>
+#include <QString>
+
+namespace Technic
+{
+ struct Modpack {
+ QString slug;
+
+ QString name;
+ QString logoUrl;
+ QString logoName;
+
+ bool broken = true;
+
+ QString url;
+ bool isSolder = false;
+ QString minecraftVersion;
+
+ bool metadataLoaded = false;
+ QString websiteUrl;
+ QString author;
+ QString description;
+ };
+} // namespace Technic
+
+Q_DECLARE_METATYPE(Technic::Modpack)
diff --git a/meshmc/launcher/ui/pages/modplatform/technic/TechnicModel.cpp b/meshmc/launcher/ui/pages/modplatform/technic/TechnicModel.cpp
new file mode 100644
index 0000000000..7d4a6bd2ba
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/technic/TechnicModel.cpp
@@ -0,0 +1,245 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-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 "TechnicModel.h"
+#include "Application.h"
+#include "Json.h"
+
+#include <QIcon>
+
+Technic::ListModel::ListModel(QObject* parent) : QAbstractListModel(parent) {}
+
+Technic::ListModel::~ListModel() {}
+
+QVariant Technic::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);
+ }
+
+ Modpack pack = modpacks.at(pos);
+ if (role == Qt::DisplayRole) {
+ return pack.name;
+ } 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();
+}
+
+int Technic::ListModel::columnCount(const QModelIndex&) const
+{
+ return 1;
+}
+
+int Technic::ListModel::rowCount(const QModelIndex&) const
+{
+ return modpacks.size();
+}
+
+void Technic::ListModel::searchWithTerm(const QString& term)
+{
+ if (currentSearchTerm == term &&
+ currentSearchTerm.isNull() == term.isNull()) {
+ return;
+ }
+ currentSearchTerm = term;
+ if (jobPtr) {
+ jobPtr->abort();
+ searchState = ResetRequested;
+ return;
+ } else {
+ beginResetModel();
+ modpacks.clear();
+ endResetModel();
+ searchState = None;
+ }
+ performSearch();
+}
+
+void Technic::ListModel::performSearch()
+{
+ NetJob* netJob = new NetJob("Technic::Search", APPLICATION->network());
+ QString searchUrl = "";
+ if (currentSearchTerm.isEmpty()) {
+ searchUrl = "https://api.technicpack.net/trending?build=meshmc";
+ } else {
+ searchUrl =
+ QString("https://api.technicpack.net/search?build=meshmc&q=%1")
+ .arg(currentSearchTerm);
+ }
+ 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 Technic::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 Technic at "
+ << parse_error.offset
+ << " reason: " << parse_error.errorString();
+ qWarning() << response;
+ return;
+ }
+
+ QList<Modpack> newList;
+ try {
+ auto root = Json::requireObject(doc);
+ auto objs = Json::requireArray(root, "modpacks");
+ for (auto technicPack : objs) {
+ Modpack pack;
+ auto technicPackObject = Json::requireObject(technicPack);
+ pack.name = Json::requireString(technicPackObject, "name");
+ pack.slug = Json::requireString(technicPackObject, "slug");
+ if (pack.slug == "vanilla")
+ continue;
+
+ auto rawURL =
+ Json::ensureString(technicPackObject, "iconUrl", "null");
+ if (rawURL == "null") {
+ pack.logoUrl = "null";
+ pack.logoName = "null";
+ } else {
+ pack.logoUrl = rawURL;
+ pack.logoName = rawURL.section(QLatin1Char('/'), -1)
+ .section(QLatin1Char('.'), 0, 0);
+ }
+ pack.broken = false;
+ newList.append(pack);
+ }
+ } catch (const JSONValidationError& err) {
+ qCritical() << "Couldn't parse technic search results:" << err.cause();
+ return;
+ }
+ searchState = Finished;
+ beginInsertRows(QModelIndex(), modpacks.size(),
+ modpacks.size() + newList.size() - 1);
+ modpacks.append(newList);
+ endInsertRows();
+}
+
+void Technic::ListModel::getLogo(const QString& logo, const QString& logoUrl,
+ Technic::LogoCallback callback)
+{
+ if (m_logoMap.contains(logo)) {
+ callback(
+ APPLICATION->metacache()
+ ->resolveEntry("TechnicPacks", QString("logos/%1").arg(logo))
+ ->getFullPath());
+ } else {
+ requestLogo(logo, logoUrl);
+ }
+}
+
+void Technic::ListModel::searchRequestFailed()
+{
+ jobPtr.reset();
+
+ if (searchState == ResetRequested) {
+ beginResetModel();
+ modpacks.clear();
+ endResetModel();
+
+ performSearch();
+ } else {
+ searchState = Finished;
+ }
+}
+
+void Technic::ListModel::logoLoaded(QString logo, QString out)
+{
+ m_loadingLogos.removeAll(logo);
+ m_logoMap.insert(logo, QIcon(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 Technic::ListModel::logoFailed(QString logo)
+{
+ m_failedLogos.append(logo);
+ m_loadingLogos.removeAll(logo);
+}
+
+void Technic::ListModel::requestLogo(QString logo, QString url)
+{
+ if (m_loadingLogos.contains(logo) || m_failedLogos.contains(logo) ||
+ logo == "null") {
+ return;
+ }
+
+ MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry(
+ "TechnicPacks", QString("logos/%1").arg(logo));
+ NetJob* job = new NetJob(QString("Technic 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] { logoLoaded(logo, fullPath); });
+
+ QObject::connect(job, &NetJob::failed, this,
+ [this, logo] { logoFailed(logo); });
+
+ job->start();
+
+ m_loadingLogos.append(logo);
+}
diff --git a/meshmc/launcher/ui/pages/modplatform/technic/TechnicModel.h b/meshmc/launcher/ui/pages/modplatform/technic/TechnicModel.h
new file mode 100644
index 0000000000..cf52303ac0
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/technic/TechnicModel.h
@@ -0,0 +1,91 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2020-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.
+ */
+
+#pragma once
+
+#include <QModelIndex>
+
+#include "TechnicData.h"
+#include "net/NetJob.h"
+
+namespace Technic
+{
+
+ typedef std::function<void(QString)> LogoCallback;
+
+ class ListModel : public QAbstractListModel
+ {
+ Q_OBJECT
+
+ public:
+ ListModel(QObject* parent);
+ virtual ~ListModel();
+
+ virtual QVariant data(const QModelIndex& index, int role) const;
+ virtual int columnCount(const QModelIndex& parent) const;
+ virtual int rowCount(const QModelIndex& parent) const;
+
+ void getLogo(const QString& logo, const QString& logoUrl,
+ LogoCallback callback);
+ void searchWithTerm(const QString& term);
+
+ private slots:
+ void searchRequestFinished();
+ void searchRequestFailed();
+
+ void logoFailed(QString logo);
+ void logoLoaded(QString logo, QString out);
+
+ private:
+ void performSearch();
+ void requestLogo(QString logo, QString url);
+
+ private:
+ QList<Modpack> modpacks;
+ QStringList m_failedLogos;
+ QStringList m_loadingLogos;
+ QMap<QString, QIcon> m_logoMap;
+ QMap<QString, LogoCallback> waitingCallbacks;
+
+ QString currentSearchTerm;
+ enum SearchState { None, ResetRequested, Finished } searchState = None;
+ NetJob::Ptr jobPtr;
+ QByteArray response;
+ };
+
+} // namespace Technic
diff --git a/meshmc/launcher/ui/pages/modplatform/technic/TechnicPage.cpp b/meshmc/launcher/ui/pages/modplatform/technic/TechnicPage.cpp
new file mode 100644
index 0000000000..0c7c5a0ec7
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/technic/TechnicPage.cpp
@@ -0,0 +1,228 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "TechnicPage.h"
+#include "ui_TechnicPage.h"
+
+#include <QKeyEvent>
+
+#include "ui/dialogs/NewInstanceDialog.h"
+
+#include "TechnicModel.h"
+#include "modplatform/technic/SingleZipPackInstallTask.h"
+#include "modplatform/technic/SolderPackInstallTask.h"
+#include "Json.h"
+
+#include "Application.h"
+
+TechnicPage::TechnicPage(NewInstanceDialog* dialog, QWidget* parent)
+ : QWidget(parent), ui(new Ui::TechnicPage), dialog(dialog)
+{
+ ui->setupUi(this);
+ connect(ui->searchButton, &QPushButton::clicked, this,
+ &TechnicPage::triggerSearch);
+ ui->searchEdit->installEventFilter(this);
+ model = new Technic::ListModel(this);
+ ui->packView->setModel(model);
+ connect(ui->packView->selectionModel(),
+ &QItemSelectionModel::currentChanged, this,
+ &TechnicPage::onSelectionChanged);
+}
+
+bool TechnicPage::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);
+}
+
+TechnicPage::~TechnicPage()
+{
+ delete ui;
+}
+
+bool TechnicPage::shouldDisplay() const
+{
+ return true;
+}
+
+void TechnicPage::openedImpl()
+{
+ suggestCurrent();
+ triggerSearch();
+}
+
+void TechnicPage::triggerSearch()
+{
+ model->searchWithTerm(ui->searchEdit->text());
+}
+
+void TechnicPage::onSelectionChanged(QModelIndex first, QModelIndex second)
+{
+ if (!first.isValid()) {
+ if (isOpened) {
+ dialog->setSuggestedPack();
+ }
+ // ui->frame->clear();
+ return;
+ }
+
+ current = model->data(first, Qt::UserRole).value<Technic::Modpack>();
+ suggestCurrent();
+}
+
+void TechnicPage::suggestCurrent()
+{
+ if (!isOpened) {
+ return;
+ }
+ if (current.broken) {
+ dialog->setSuggestedPack();
+ return;
+ }
+
+ QString editedLogoName = "technic_" + current.logoName.section(".", 0, 0);
+ model->getLogo(current.logoName, current.logoUrl,
+ [this, editedLogoName](QString logo) {
+ dialog->setSuggestedIconFromFile(logo, editedLogoName);
+ });
+
+ if (current.metadataLoaded) {
+ metadataLoaded();
+ return;
+ }
+
+ NetJob* netJob =
+ new NetJob(QString("Technic::PackMeta(%1)").arg(current.name),
+ APPLICATION->network());
+ std::shared_ptr<QByteArray> response = std::make_shared<QByteArray>();
+ QString slug = current.slug;
+ netJob->addNetAction(Net::Download::makeByteArray(
+ QString("https://api.technicpack.net/modpack/%1?build=meshmc")
+ .arg(slug),
+ response.get()));
+ QObject::connect(netJob, &NetJob::succeeded, this, [this, response, slug] {
+ if (current.slug != slug) {
+ return;
+ }
+ QJsonParseError parse_error;
+ QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error);
+ QJsonObject obj = doc.object();
+ if (parse_error.error != QJsonParseError::NoError) {
+ qWarning() << "Error while parsing JSON response from Technic at "
+ << parse_error.offset
+ << " reason: " << parse_error.errorString();
+ qWarning() << *response;
+ return;
+ }
+ if (!obj.contains("url")) {
+ qWarning() << "Json doesn't contain an url key";
+ return;
+ }
+ QJsonValueRef url = obj["url"];
+ if (url.isString()) {
+ current.url = url.toString();
+ } else {
+ if (!obj.contains("solder")) {
+ qWarning() << "Json doesn't contain a valid url or solder key";
+ return;
+ }
+ QJsonValueRef solderUrl = obj["solder"];
+ if (solderUrl.isString()) {
+ current.url = solderUrl.toString();
+ current.isSolder = true;
+ } else {
+ qWarning() << "Json doesn't contain a valid url or solder key";
+ return;
+ }
+ }
+
+ current.minecraftVersion =
+ Json::ensureString(obj, "minecraft", QString(), "__placeholder__");
+ current.websiteUrl = Json::ensureString(obj, "platformUrl", QString(),
+ "__placeholder__");
+ current.author =
+ Json::ensureString(obj, "user", QString(), "__placeholder__");
+ current.description = Json::ensureString(obj, "description", QString(),
+ "__placeholder__");
+ current.metadataLoaded = true;
+ metadataLoaded();
+ });
+ netJob->start();
+}
+
+// expects current.metadataLoaded to be true
+void TechnicPage::metadataLoaded()
+{
+ QString text = "";
+ QString name = current.name;
+
+ if (current.websiteUrl.isEmpty())
+ // This allows injecting HTML here.
+ text = name;
+ else
+ // URL not properly escaped for inclusion in HTML. The name allows for
+ // injecting HTML.
+ text = "<a href=\"" + current.websiteUrl + "\">" + name + "</a>";
+ if (!current.author.isEmpty()) {
+ // This allows injecting HTML here
+ text += tr(" by ") + current.author;
+ }
+
+ ui->frame->setModText(text);
+ ui->frame->setModDescription(current.description);
+ if (!current.isSolder) {
+ dialog->setSuggestedPack(current.name,
+ new Technic::SingleZipPackInstallTask(
+ current.url, current.minecraftVersion));
+ } else {
+ while (current.url.endsWith('/'))
+ current.url.chop(1);
+ dialog->setSuggestedPack(current.name,
+ new Technic::SolderPackInstallTask(
+ APPLICATION->network(),
+ current.url + "/modpack/" + current.slug,
+ current.minecraftVersion));
+ }
+}
diff --git a/meshmc/launcher/ui/pages/modplatform/technic/TechnicPage.h b/meshmc/launcher/ui/pages/modplatform/technic/TechnicPage.h
new file mode 100644
index 0000000000..c6d9aaf33f
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/technic/TechnicPage.h
@@ -0,0 +1,102 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+#include "ui/pages/BasePage.h"
+#include <Application.h>
+#include "tasks/Task.h"
+#include "TechnicData.h"
+
+namespace Ui
+{
+ class TechnicPage;
+}
+
+class NewInstanceDialog;
+
+namespace Technic
+{
+ class ListModel;
+}
+
+class TechnicPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+ public:
+ explicit TechnicPage(NewInstanceDialog* dialog, QWidget* parent = 0);
+ virtual ~TechnicPage();
+ virtual QString displayName() const override
+ {
+ return tr("Technic");
+ }
+ virtual QIcon icon() const override
+ {
+ return APPLICATION->getThemedIcon("technic");
+ }
+ virtual QString id() const override
+ {
+ return "technic";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Technic-platform";
+ }
+ virtual bool shouldDisplay() const override;
+
+ void openedImpl() override;
+
+ bool eventFilter(QObject* watched, QEvent* event) override;
+
+ private:
+ void suggestCurrent();
+ void metadataLoaded();
+
+ private slots:
+ void triggerSearch();
+ void onSelectionChanged(QModelIndex first, QModelIndex second);
+
+ private:
+ Ui::TechnicPage* ui = nullptr;
+ NewInstanceDialog* dialog = nullptr;
+ Technic::ListModel* model = nullptr;
+ Technic::Modpack current;
+};
diff --git a/meshmc/launcher/ui/pages/modplatform/technic/TechnicPage.ui b/meshmc/launcher/ui/pages/modplatform/technic/TechnicPage.ui
new file mode 100644
index 0000000000..dde685d95c
--- /dev/null
+++ b/meshmc/launcher/ui/pages/modplatform/technic/TechnicPage.ui
@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>TechnicPage</class>
+ <widget class="QWidget" name="TechnicPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>546</width>
+ <height>405</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QWidget" name="widget" native="true">
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QLineEdit" name="searchEdit">
+ <property name="placeholderText">
+ <string>Search and filter ...</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="searchButton">
+ <property name="text">
+ <string>Search</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QListView" name="packView">
+ <property name="horizontalScrollBarPolicy">
+ <enum>Qt::ScrollBarAlwaysOff</enum>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>48</width>
+ <height>48</height>
+ </size>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="MCModInfoFrame" name="frame">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Minimum">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="frameShape">
+ <enum>QFrame::StyledPanel</enum>
+ </property>
+ <property name="frameShadow">
+ <enum>QFrame::Raised</enum>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>MCModInfoFrame</class>
+ <extends>QFrame</extends>
+ <header>ui/widgets/MCModInfoFrame.h</header>
+ <container>1</container>
+ </customwidget>
+ </customwidgets>
+ <tabstops>
+ <tabstop>searchEdit</tabstop>
+ <tabstop>searchButton</tabstop>
+ <tabstop>packView</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/setupwizard/AnalyticsWizardPage.cpp b/meshmc/launcher/ui/setupwizard/AnalyticsWizardPage.cpp
new file mode 100644
index 0000000000..0627f9bbc0
--- /dev/null
+++ b/meshmc/launcher/ui/setupwizard/AnalyticsWizardPage.cpp
@@ -0,0 +1,88 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "AnalyticsWizardPage.h"
+#include <Application.h>
+
+#include <QVBoxLayout>
+#include <QTextBrowser>
+#include <QCheckBox>
+
+#include <ganalytics.h>
+#include <BuildConfig.h>
+
+AnalyticsWizardPage::AnalyticsWizardPage(QWidget* parent)
+ : BaseWizardPage(parent)
+{
+ setObjectName(QStringLiteral("analyticsPage"));
+ verticalLayout_3 = new QVBoxLayout(this);
+ verticalLayout_3->setObjectName(QStringLiteral("verticalLayout_3"));
+ textBrowser = new QTextBrowser(this);
+ textBrowser->setObjectName(QStringLiteral("textBrowser"));
+ textBrowser->setAcceptRichText(false);
+ textBrowser->setOpenExternalLinks(true);
+ verticalLayout_3->addWidget(textBrowser);
+
+ checkBox = new QCheckBox(this);
+ checkBox->setObjectName(QStringLiteral("checkBox"));
+ checkBox->setChecked(true);
+ verticalLayout_3->addWidget(checkBox);
+ retranslate();
+}
+
+AnalyticsWizardPage::~AnalyticsWizardPage() {}
+
+bool AnalyticsWizardPage::validatePage()
+{
+ auto settings = APPLICATION->settings();
+ auto analytics = APPLICATION->analytics();
+ auto status = checkBox->isChecked();
+ settings->set("AnalyticsSeen", analytics->version());
+ settings->set("Analytics", status);
+ return true;
+}
+
+void AnalyticsWizardPage::retranslate()
+{
+ setTitle(tr("Analytics"));
+ setSubTitle(tr("We track some anonymous statistics about users."));
+ textBrowser->setHtml(
+ tr("<html><body>"
+ "<p>MeshMC sends anonymous usage statistics on every start of the "
+ "application. This helps us decide what platforms and issues to "
+ "focus on.</p>"
+ "<p>The data is processed by Google Analytics, see their <a "
+ "href=\"https://support.google.com/analytics/answer/"
+ "6004245?hl=en\">article on the "
+ "matter</a>.</p>"
+ "<p>The following data is collected:</p>"
+ "<ul><li>A random unique ID of the installation.<br />It is stored "
+ "in the application settings file.</li>"
+ "<li>Anonymized (partial) IP address.</li>"
+ "<li>MeshMC version.</li>"
+ "<li>Operating system name, version and architecture.</li>"
+ "<li>CPU architecture (kernel architecture on linux).</li>"
+ "<li>Size of system memory.</li>"
+ "<li>Java version, architecture and memory settings.</li></ul>"
+ "<p>If we change the tracked information, you will see this page "
+ "again.</p></body></html>"));
+ checkBox->setText(tr("Enable Analytics"));
+}
diff --git a/meshmc/launcher/ui/setupwizard/AnalyticsWizardPage.h b/meshmc/launcher/ui/setupwizard/AnalyticsWizardPage.h
new file mode 100644
index 0000000000..d304a2e059
--- /dev/null
+++ b/meshmc/launcher/ui/setupwizard/AnalyticsWizardPage.h
@@ -0,0 +1,46 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "BaseWizardPage.h"
+
+class QVBoxLayout;
+class QTextBrowser;
+class QCheckBox;
+
+class AnalyticsWizardPage : public BaseWizardPage
+{
+ Q_OBJECT
+ public:
+ explicit AnalyticsWizardPage(QWidget* parent = Q_NULLPTR);
+ virtual ~AnalyticsWizardPage();
+
+ bool validatePage() override;
+
+ protected:
+ void retranslate() override;
+
+ private:
+ QVBoxLayout* verticalLayout_3 = nullptr;
+ QTextBrowser* textBrowser = nullptr;
+ QCheckBox* checkBox = nullptr;
+}; \ No newline at end of file
diff --git a/meshmc/launcher/ui/setupwizard/BaseWizardPage.h b/meshmc/launcher/ui/setupwizard/BaseWizardPage.h
new file mode 100644
index 0000000000..255e4a871d
--- /dev/null
+++ b/meshmc/launcher/ui/setupwizard/BaseWizardPage.h
@@ -0,0 +1,50 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QWizardPage>
+#include <QEvent>
+
+class BaseWizardPage : public QWizardPage
+{
+ public:
+ explicit BaseWizardPage(QWidget* parent = Q_NULLPTR) : QWizardPage(parent)
+ {
+ }
+ virtual ~BaseWizardPage() {};
+
+ virtual bool wantsRefreshButton()
+ {
+ return false;
+ }
+ virtual void refresh() {}
+
+ protected:
+ virtual void retranslate() = 0;
+ void changeEvent(QEvent* event) override
+ {
+ if (event->type() == QEvent::LanguageChange) {
+ retranslate();
+ }
+ QWizardPage::changeEvent(event);
+ }
+};
diff --git a/meshmc/launcher/ui/setupwizard/JavaWizardPage.cpp b/meshmc/launcher/ui/setupwizard/JavaWizardPage.cpp
new file mode 100644
index 0000000000..5d00ff164b
--- /dev/null
+++ b/meshmc/launcher/ui/setupwizard/JavaWizardPage.cpp
@@ -0,0 +1,111 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "JavaWizardPage.h"
+#include "Application.h"
+
+#include <QVBoxLayout>
+#include <QGroupBox>
+#include <QSpinBox>
+#include <QLabel>
+#include <QLineEdit>
+#include <QPushButton>
+#include <QToolButton>
+#include <QFileDialog>
+
+#include <sys.h>
+
+#include "FileSystem.h"
+#include "java/JavaInstall.h"
+#include "java/JavaUtils.h"
+#include "JavaCommon.h"
+
+#include "ui/widgets/VersionSelectWidget.h"
+#include "ui/dialogs/CustomMessageBox.h"
+#include "ui/widgets/JavaSettingsWidget.h"
+
+JavaWizardPage::JavaWizardPage(QWidget* parent) : BaseWizardPage(parent)
+{
+ setupUi();
+}
+
+void JavaWizardPage::setupUi()
+{
+ setObjectName(QStringLiteral("javaPage"));
+ QVBoxLayout* layout = new QVBoxLayout(this);
+
+ m_java_widget = new JavaSettingsWidget(this);
+ layout->addWidget(m_java_widget);
+ setLayout(layout);
+
+ retranslate();
+}
+
+void JavaWizardPage::refresh()
+{
+ m_java_widget->refresh();
+}
+
+void JavaWizardPage::initializePage()
+{
+ m_java_widget->initialize();
+}
+
+bool JavaWizardPage::wantsRefreshButton()
+{
+ return true;
+}
+
+bool JavaWizardPage::validatePage()
+{
+ auto settings = APPLICATION->settings();
+ auto result = m_java_widget->validate();
+ switch (result) {
+ default:
+ case JavaSettingsWidget::ValidationStatus::Bad: {
+ return false;
+ }
+ case JavaSettingsWidget::ValidationStatus::AllOK: {
+ settings->set("JavaPath", m_java_widget->javaPath());
+ }
+ case JavaSettingsWidget::ValidationStatus::JavaBad: {
+ // Memory
+ auto s = APPLICATION->settings();
+ s->set("MinMemAlloc", m_java_widget->minHeapSize());
+ s->set("MaxMemAlloc", m_java_widget->maxHeapSize());
+ if (m_java_widget->permGenEnabled()) {
+ s->set("PermGen", m_java_widget->permGenSize());
+ } else {
+ s->reset("PermGen");
+ }
+ return true;
+ }
+ }
+}
+
+void JavaWizardPage::retranslate()
+{
+ setTitle(tr("Java"));
+ setSubTitle(tr(
+ "You do not have a working Java set up yet or it went missing.\n"
+ "Please select one of the following or browse for a java executable."));
+ m_java_widget->retranslate();
+}
diff --git a/meshmc/launcher/ui/setupwizard/JavaWizardPage.h b/meshmc/launcher/ui/setupwizard/JavaWizardPage.h
new file mode 100644
index 0000000000..8ffd2e83df
--- /dev/null
+++ b/meshmc/launcher/ui/setupwizard/JavaWizardPage.h
@@ -0,0 +1,47 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "BaseWizardPage.h"
+
+class JavaSettingsWidget;
+
+class JavaWizardPage : public BaseWizardPage
+{
+ Q_OBJECT
+ public:
+ explicit JavaWizardPage(QWidget* parent = Q_NULLPTR);
+
+ virtual ~JavaWizardPage() {};
+
+ bool wantsRefreshButton() override;
+ void refresh() override;
+ void initializePage() override;
+ bool validatePage() override;
+
+ protected: /* methods */
+ void setupUi();
+ void retranslate() override;
+
+ private: /* data */
+ JavaSettingsWidget* m_java_widget = nullptr;
+};
diff --git a/meshmc/launcher/ui/setupwizard/LanguageWizardPage.cpp b/meshmc/launcher/ui/setupwizard/LanguageWizardPage.cpp
new file mode 100644
index 0000000000..20915c9c3e
--- /dev/null
+++ b/meshmc/launcher/ui/setupwizard/LanguageWizardPage.cpp
@@ -0,0 +1,68 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "LanguageWizardPage.h"
+#include <Application.h>
+#include <translations/TranslationsModel.h>
+
+#include "ui/widgets/LanguageSelectionWidget.h"
+#include <QVBoxLayout>
+#include <BuildConfig.h>
+
+LanguageWizardPage::LanguageWizardPage(QWidget* parent) : BaseWizardPage(parent)
+{
+ setObjectName(QStringLiteral("languagePage"));
+ auto layout = new QVBoxLayout(this);
+ mainWidget = new LanguageSelectionWidget(this);
+ layout->setContentsMargins(0, 0, 0, 0);
+ layout->addWidget(mainWidget);
+
+ retranslate();
+}
+
+LanguageWizardPage::~LanguageWizardPage() {}
+
+bool LanguageWizardPage::wantsRefreshButton()
+{
+ return true;
+}
+
+void LanguageWizardPage::refresh()
+{
+ auto translations = APPLICATION->translations();
+ translations->downloadIndex();
+}
+
+bool LanguageWizardPage::validatePage()
+{
+ auto settings = APPLICATION->settings();
+ QString key = mainWidget->getSelectedLanguageKey();
+ settings->set("Language", key);
+ return true;
+}
+
+void LanguageWizardPage::retranslate()
+{
+ setTitle(tr("Language"));
+ setSubTitle(
+ tr("Select the language to use in %1").arg(BuildConfig.MESHMC_NAME));
+ mainWidget->retranslate();
+}
diff --git a/meshmc/launcher/ui/setupwizard/LanguageWizardPage.h b/meshmc/launcher/ui/setupwizard/LanguageWizardPage.h
new file mode 100644
index 0000000000..bcd68b3e57
--- /dev/null
+++ b/meshmc/launcher/ui/setupwizard/LanguageWizardPage.h
@@ -0,0 +1,47 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "BaseWizardPage.h"
+
+class LanguageSelectionWidget;
+
+class LanguageWizardPage : public BaseWizardPage
+{
+ Q_OBJECT
+ public:
+ explicit LanguageWizardPage(QWidget* parent = Q_NULLPTR);
+
+ virtual ~LanguageWizardPage();
+
+ bool wantsRefreshButton() override;
+
+ void refresh() override;
+
+ bool validatePage() override;
+
+ protected:
+ void retranslate() override;
+
+ private:
+ LanguageSelectionWidget* mainWidget = nullptr;
+};
diff --git a/meshmc/launcher/ui/setupwizard/SetupWizard.cpp b/meshmc/launcher/ui/setupwizard/SetupWizard.cpp
new file mode 100644
index 0000000000..57642238f2
--- /dev/null
+++ b/meshmc/launcher/ui/setupwizard/SetupWizard.cpp
@@ -0,0 +1,105 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "SetupWizard.h"
+
+#include "LanguageWizardPage.h"
+#include "JavaWizardPage.h"
+#include "AnalyticsWizardPage.h"
+
+#include "translations/TranslationsModel.h"
+#include <Application.h>
+#include <FileSystem.h>
+#include <ganalytics.h>
+
+#include <QAbstractButton>
+#include <BuildConfig.h>
+
+SetupWizard::SetupWizard(QWidget* parent) : QWizard(parent)
+{
+ setObjectName(QStringLiteral("SetupWizard"));
+ resize(615, 659);
+ // make it ugly everywhere to avoid variability in theming
+ setWizardStyle(QWizard::ClassicStyle);
+ setOptions(QWizard::NoCancelButton | QWizard::IndependentPages |
+ QWizard::HaveCustomButton1);
+
+ retranslate();
+
+ connect(this, &QWizard::currentIdChanged, this, &SetupWizard::pageChanged);
+}
+
+void SetupWizard::retranslate()
+{
+ setButtonText(QWizard::NextButton, tr("&Next >"));
+ setButtonText(QWizard::BackButton, tr("< &Back"));
+ setButtonText(QWizard::FinishButton, tr("&Finish"));
+ setButtonText(QWizard::CustomButton1, tr("&Refresh"));
+ setWindowTitle(tr("%1 Quick Setup").arg(BuildConfig.MESHMC_NAME));
+}
+
+BaseWizardPage* SetupWizard::getBasePage(int id)
+{
+ if (id == -1)
+ return nullptr;
+ auto pagePtr = page(id);
+ if (!pagePtr)
+ return nullptr;
+ return dynamic_cast<BaseWizardPage*>(pagePtr);
+}
+
+BaseWizardPage* SetupWizard::getCurrentBasePage()
+{
+ return getBasePage(currentId());
+}
+
+void SetupWizard::pageChanged(int id)
+{
+ auto basePagePtr = getBasePage(id);
+ if (!basePagePtr) {
+ return;
+ }
+ if (basePagePtr->wantsRefreshButton()) {
+ setButtonLayout({QWizard::CustomButton1, QWizard::Stretch,
+ QWizard::BackButton, QWizard::NextButton,
+ QWizard::FinishButton});
+ auto customButton = button(QWizard::CustomButton1);
+ connect(customButton, &QAbstractButton::pressed, [&]() {
+ auto basePagePtr = getCurrentBasePage();
+ if (basePagePtr) {
+ basePagePtr->refresh();
+ }
+ });
+ } else {
+ setButtonLayout({QWizard::Stretch, QWizard::BackButton,
+ QWizard::NextButton, QWizard::FinishButton});
+ }
+}
+
+void SetupWizard::changeEvent(QEvent* event)
+{
+ if (event->type() == QEvent::LanguageChange) {
+ retranslate();
+ }
+ QWizard::changeEvent(event);
+}
+
+SetupWizard::~SetupWizard() {}
diff --git a/meshmc/launcher/ui/setupwizard/SetupWizard.h b/meshmc/launcher/ui/setupwizard/SetupWizard.h
new file mode 100644
index 0000000000..871385ca37
--- /dev/null
+++ b/meshmc/launcher/ui/setupwizard/SetupWizard.h
@@ -0,0 +1,67 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2017-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.
+ */
+
+#pragma once
+
+#include <QWizard>
+
+namespace Ui
+{
+ class SetupWizard;
+}
+
+class BaseWizardPage;
+
+class SetupWizard : public QWizard
+{
+ Q_OBJECT
+
+ public: /* con/destructors */
+ explicit SetupWizard(QWidget* parent = 0);
+ virtual ~SetupWizard();
+
+ void changeEvent(QEvent* event) override;
+ BaseWizardPage* getBasePage(int id);
+ BaseWizardPage* getCurrentBasePage();
+
+ private slots:
+ void pageChanged(int id);
+
+ private: /* methods */
+ void retranslate();
+};
diff --git a/meshmc/launcher/ui/themes/BrightTheme.cpp b/meshmc/launcher/ui/themes/BrightTheme.cpp
new file mode 100644
index 0000000000..074f5b6592
--- /dev/null
+++ b/meshmc/launcher/ui/themes/BrightTheme.cpp
@@ -0,0 +1,83 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "BrightTheme.h"
+
+#include <QObject>
+
+QString BrightTheme::id()
+{
+ return "bright";
+}
+
+QString BrightTheme::name()
+{
+ return QObject::tr("Bright");
+}
+
+QString BrightTheme::tooltip()
+{
+ return QObject::tr("A bright Fusion-based theme with green accents");
+}
+
+bool BrightTheme::hasColorScheme()
+{
+ return true;
+}
+
+QPalette BrightTheme::colorScheme()
+{
+ QPalette brightPalette;
+ brightPalette.setColor(QPalette::Window, QColor(255, 255, 255));
+ brightPalette.setColor(QPalette::WindowText, QColor(49, 49, 49));
+ brightPalette.setColor(QPalette::Base, QColor(250, 250, 250));
+ brightPalette.setColor(QPalette::AlternateBase, QColor(239, 240, 241));
+ brightPalette.setColor(QPalette::ToolTipBase, QColor(49, 49, 49));
+ brightPalette.setColor(QPalette::ToolTipText, QColor(239, 240, 241));
+ brightPalette.setColor(QPalette::Text, QColor(49, 49, 49));
+ brightPalette.setColor(QPalette::Button, QColor(255, 255, 255));
+ brightPalette.setColor(QPalette::ButtonText, QColor(49, 49, 49));
+ brightPalette.setColor(QPalette::BrightText, Qt::red);
+ brightPalette.setColor(QPalette::Link, QColor(37, 137, 164));
+ brightPalette.setColor(QPalette::Highlight, QColor(137, 207, 84));
+ brightPalette.setColor(QPalette::HighlightedText, QColor(239, 240, 241));
+ return fadeInactive(brightPalette, fadeAmount(), fadeColor());
+}
+
+double BrightTheme::fadeAmount()
+{
+ return 0.5;
+}
+
+QColor BrightTheme::fadeColor()
+{
+ return QColor(255, 255, 255);
+}
+
+bool BrightTheme::hasStyleSheet()
+{
+ return false;
+}
+
+QString BrightTheme::appStyleSheet()
+{
+ return QString();
+}
diff --git a/meshmc/launcher/ui/themes/BrightTheme.h b/meshmc/launcher/ui/themes/BrightTheme.h
new file mode 100644
index 0000000000..4298cf8ee8
--- /dev/null
+++ b/meshmc/launcher/ui/themes/BrightTheme.h
@@ -0,0 +1,40 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "FusionTheme.h"
+
+class BrightTheme : public FusionTheme
+{
+ public:
+ virtual ~BrightTheme() {}
+
+ QString id() override;
+ QString name() override;
+ QString tooltip() override;
+ bool hasStyleSheet() override;
+ QString appStyleSheet() override;
+ bool hasColorScheme() override;
+ QPalette colorScheme() override;
+ double fadeAmount() override;
+ QColor fadeColor() override;
+};
diff --git a/meshmc/launcher/ui/themes/CatPack.cpp b/meshmc/launcher/ui/themes/CatPack.cpp
new file mode 100644
index 0000000000..788cbf2d5d
--- /dev/null
+++ b/meshmc/launcher/ui/themes/CatPack.cpp
@@ -0,0 +1,166 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "CatPack.h"
+#include "Exception.h"
+
+#include <QDate>
+#include <QDir>
+#include <QFile>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+
+// ===================== BasicCatPack =====================
+
+BasicCatPack::BasicCatPack(const QString& id, const QString& name)
+ : m_id(id), m_name(name)
+{
+}
+
+QString BasicCatPack::id()
+{
+ return m_id;
+}
+
+QString BasicCatPack::name()
+{
+ return m_name;
+}
+
+QString BasicCatPack::path()
+{
+ QDate now = QDate::currentDate();
+ int month = now.month();
+ int day = now.day();
+
+ // Christmas: Dec 21 - Dec 29
+ if ((month == 12 && day >= 21 && day <= 29))
+ return QString(":/backgrounds/%1-xmas").arg(m_id);
+
+ // Spooky: Oct 27 - Nov 2
+ if ((month == 10 && day >= 27) || (month == 11 && day <= 2))
+ return QString(":/backgrounds/%1-spooky").arg(m_id);
+
+ // Birthday: Oct 28 - Nov 5
+ if ((month == 10 && day >= 28) || (month == 11 && day <= 5))
+ return QString(":/backgrounds/%1-bday").arg(m_id);
+
+ return QString(":/backgrounds/%1").arg(m_id);
+}
+
+// ===================== FileCatPack =====================
+
+FileCatPack::FileCatPack(const QFileInfo& fileInfo) : m_fileInfo(fileInfo) {}
+
+QString FileCatPack::id()
+{
+ return m_fileInfo.baseName();
+}
+
+QString FileCatPack::name()
+{
+ return m_fileInfo.baseName();
+}
+
+QString FileCatPack::path()
+{
+ return m_fileInfo.absoluteFilePath();
+}
+
+// ===================== JsonCatPack =====================
+
+JsonCatPack::JsonCatPack(const QFileInfo& manifestInfo)
+{
+ QFile file(manifestInfo.absoluteFilePath());
+ if (!file.open(QFile::ReadOnly))
+ throw Exception(QString("Could not open catpack manifest: %1")
+ .arg(manifestInfo.absoluteFilePath()));
+
+ QJsonParseError parseError;
+ auto doc = QJsonDocument::fromJson(file.readAll(), &parseError);
+ if (parseError.error != QJsonParseError::NoError)
+ throw Exception(QString("catpack.json parse error: %1")
+ .arg(parseError.errorString()));
+
+ auto root = doc.object();
+ m_id = manifestInfo.dir().dirName();
+ m_name = root.value("name").toString(m_id);
+ m_defaultPath = QDir(manifestInfo.absolutePath())
+ .absoluteFilePath(root.value("default").toString());
+
+ auto variants = root.value("variants").toArray();
+ for (const auto& val : variants) {
+ auto obj = val.toObject();
+ JsonCatPackVariant v;
+ v.path = QDir(manifestInfo.absolutePath())
+ .absoluteFilePath(obj.value("path").toString());
+
+ auto startObj = obj.value("startTime").toObject();
+ v.startMonth = startObj.value("month").toInt();
+ v.startDay = startObj.value("day").toInt();
+
+ auto endObj = obj.value("endTime").toObject();
+ v.endMonth = endObj.value("month").toInt();
+ v.endDay = endObj.value("day").toInt();
+
+ m_variants.append(v);
+ }
+}
+
+QString JsonCatPack::id()
+{
+ return m_id;
+}
+
+QString JsonCatPack::name()
+{
+ return m_name;
+}
+
+QString JsonCatPack::path()
+{
+ QDate now = QDate::currentDate();
+
+ for (const auto& v : m_variants) {
+ bool inRange = false;
+ if (v.startMonth <= v.endMonth) {
+ // Same year range: e.g., Mar 1 - Jun 30
+ QDate start(now.year(), v.startMonth, v.startDay);
+ QDate end(now.year(), v.endMonth, v.endDay);
+ inRange = (now >= start && now <= end);
+ } else {
+ // Wraps around year boundary: e.g., Dec 20 - Jan 5
+ QDate startThisYear(now.year(), v.startMonth, v.startDay);
+ QDate endNextYear(now.year() + 1, v.endMonth, v.endDay);
+ QDate startLastYear(now.year() - 1, v.startMonth, v.startDay);
+ QDate endThisYear(now.year(), v.endMonth, v.endDay);
+
+ inRange = (now >= startThisYear && now <= endNextYear) ||
+ (now >= startLastYear && now <= endThisYear);
+ }
+
+ if (inRange)
+ return v.path;
+ }
+
+ return m_defaultPath;
+}
diff --git a/meshmc/launcher/ui/themes/CatPack.h b/meshmc/launcher/ui/themes/CatPack.h
new file mode 100644
index 0000000000..d69c3785dc
--- /dev/null
+++ b/meshmc/launcher/ui/themes/CatPack.h
@@ -0,0 +1,87 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QString>
+#include <QDate>
+#include <QFileInfo>
+#include <QList>
+
+class CatPack
+{
+ public:
+ virtual ~CatPack() {}
+ virtual QString id() = 0;
+ virtual QString name() = 0;
+ virtual QString path() = 0;
+};
+
+class BasicCatPack : public CatPack
+{
+ public:
+ BasicCatPack(const QString& id, const QString& name);
+
+ QString id() override;
+ QString name() override;
+ QString path() override;
+
+ private:
+ QString m_id;
+ QString m_name;
+};
+
+class FileCatPack : public CatPack
+{
+ public:
+ explicit FileCatPack(const QFileInfo& fileInfo);
+
+ QString id() override;
+ QString name() override;
+ QString path() override;
+
+ private:
+ QFileInfo m_fileInfo;
+};
+
+struct JsonCatPackVariant {
+ QString path;
+ int startMonth;
+ int startDay;
+ int endMonth;
+ int endDay;
+};
+
+class JsonCatPack : public CatPack
+{
+ public:
+ explicit JsonCatPack(const QFileInfo& manifestInfo);
+
+ QString id() override;
+ QString name() override;
+ QString path() override;
+
+ private:
+ QString m_id;
+ QString m_name;
+ QString m_defaultPath;
+ QList<JsonCatPackVariant> m_variants;
+};
diff --git a/meshmc/launcher/ui/themes/CustomTheme.cpp b/meshmc/launcher/ui/themes/CustomTheme.cpp
new file mode 100644
index 0000000000..37bdeb5c37
--- /dev/null
+++ b/meshmc/launcher/ui/themes/CustomTheme.cpp
@@ -0,0 +1,248 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "CustomTheme.h"
+#include <QDir>
+#include <Json.h>
+#include <FileSystem.h>
+
+const char* themeFile = "theme.json";
+const char* styleFile = "themeStyle.css";
+
+static bool readThemeJson(const QString& path, QPalette& palette,
+ double& fadeAmount, QColor& fadeColor, QString& name,
+ QString& widgets)
+{
+ QFileInfo pathInfo(path);
+ if (pathInfo.exists() && pathInfo.isFile()) {
+ try {
+ auto doc = Json::requireDocument(path, "Theme JSON file");
+ const QJsonObject root = doc.object();
+ name = Json::requireString(root, "name", "Theme name");
+ widgets = Json::requireString(root, "widgets", "Qt widget theme");
+ auto colorsRoot =
+ Json::requireObject(root, "colors", "colors object");
+ auto readColor = [&](QString colorName) -> QColor {
+ auto colorValue =
+ Json::ensureString(colorsRoot, colorName, QString());
+ if (!colorValue.isEmpty()) {
+ QColor color(colorValue);
+ if (!color.isValid()) {
+ qWarning() << "Color value" << colorValue << "for"
+ << colorName << "was not recognized.";
+ return QColor();
+ }
+ return color;
+ }
+ return QColor();
+ };
+ auto readAndSetColor = [&](QPalette::ColorRole role,
+ QString colorName) {
+ auto color = readColor(colorName);
+ if (color.isValid()) {
+ palette.setColor(role, color);
+ } else {
+ qDebug()
+ << "Color value for" << colorName << "was not present.";
+ }
+ };
+
+ // palette
+ readAndSetColor(QPalette::Window, "Window");
+ readAndSetColor(QPalette::WindowText, "WindowText");
+ readAndSetColor(QPalette::Base, "Base");
+ readAndSetColor(QPalette::AlternateBase, "AlternateBase");
+ readAndSetColor(QPalette::ToolTipBase, "ToolTipBase");
+ readAndSetColor(QPalette::ToolTipText, "ToolTipText");
+ readAndSetColor(QPalette::Text, "Text");
+ readAndSetColor(QPalette::Button, "Button");
+ readAndSetColor(QPalette::ButtonText, "ButtonText");
+ readAndSetColor(QPalette::BrightText, "BrightText");
+ readAndSetColor(QPalette::Link, "Link");
+ readAndSetColor(QPalette::Highlight, "Highlight");
+ readAndSetColor(QPalette::HighlightedText, "HighlightedText");
+
+ // fade
+ fadeColor = readColor("fadeColor");
+ fadeAmount = Json::ensureDouble(colorsRoot, "fadeAmount", 0.5,
+ "fade amount");
+
+ } catch (const Exception& e) {
+ qWarning() << "Couldn't load theme json: " << e.cause();
+ return false;
+ }
+ } else {
+ qDebug() << "No theme json present.";
+ return false;
+ }
+ return true;
+}
+
+static bool writeThemeJson(const QString& path, const QPalette& palette,
+ double fadeAmount, QColor fadeColor, QString name,
+ QString widgets)
+{
+ QJsonObject rootObj;
+ rootObj.insert("name", name);
+ rootObj.insert("widgets", widgets);
+
+ QJsonObject colorsObj;
+ auto insertColor = [&](QPalette::ColorRole role, QString colorName) {
+ colorsObj.insert(colorName, palette.color(role).name());
+ };
+
+ // palette
+ insertColor(QPalette::Window, "Window");
+ insertColor(QPalette::WindowText, "WindowText");
+ insertColor(QPalette::Base, "Base");
+ insertColor(QPalette::AlternateBase, "AlternateBase");
+ insertColor(QPalette::ToolTipBase, "ToolTipBase");
+ insertColor(QPalette::ToolTipText, "ToolTipText");
+ insertColor(QPalette::Text, "Text");
+ insertColor(QPalette::Button, "Button");
+ insertColor(QPalette::ButtonText, "ButtonText");
+ insertColor(QPalette::BrightText, "BrightText");
+ insertColor(QPalette::Link, "Link");
+ insertColor(QPalette::Highlight, "Highlight");
+ insertColor(QPalette::HighlightedText, "HighlightedText");
+
+ // fade
+ colorsObj.insert("fadeColor", fadeColor.name());
+ colorsObj.insert("fadeAmount", fadeAmount);
+
+ rootObj.insert("colors", colorsObj);
+ try {
+ Json::write(rootObj, path);
+ return true;
+ } catch (const Exception& e) {
+ qWarning() << "Failed to write theme json to" << path;
+ return false;
+ }
+}
+
+CustomTheme::CustomTheme(ITheme* baseTheme, QString folder)
+{
+ m_id = folder;
+ QString path = FS::PathCombine("themes", m_id);
+ QString pathResources = FS::PathCombine("themes", m_id, "resources");
+
+ qDebug() << "Loading theme" << m_id;
+
+ if (!FS::ensureFolderPathExists(path) ||
+ !FS::ensureFolderPathExists(pathResources)) {
+ qWarning() << "couldn't create folder for theme!";
+ m_palette = baseTheme->colorScheme();
+ m_styleSheet = baseTheme->appStyleSheet();
+ return;
+ }
+
+ auto themeFilePath = FS::PathCombine(path, themeFile);
+
+ m_palette = baseTheme->colorScheme();
+ if (!readThemeJson(themeFilePath, m_palette, m_fadeAmount, m_fadeColor,
+ m_name, m_widgets)) {
+ m_name = "Custom";
+ m_palette = baseTheme->colorScheme();
+ m_fadeColor = baseTheme->fadeColor();
+ m_fadeAmount = baseTheme->fadeAmount();
+ m_widgets = baseTheme->qtTheme();
+
+ QFileInfo info(themeFilePath);
+ if (!info.exists()) {
+ writeThemeJson(themeFilePath, m_palette, m_fadeAmount, m_fadeColor,
+ "Custom", m_widgets);
+ }
+ } else {
+ m_palette = fadeInactive(m_palette, m_fadeAmount, m_fadeColor);
+ }
+
+ auto cssFilePath = FS::PathCombine(path, styleFile);
+ QFileInfo info(cssFilePath);
+ if (info.isFile()) {
+ try {
+ // TODO: validate css?
+ m_styleSheet = QString::fromUtf8(FS::read(cssFilePath));
+ } catch (const Exception& e) {
+ qWarning() << "Couldn't load css:" << e.cause() << "from"
+ << cssFilePath;
+ m_styleSheet = baseTheme->appStyleSheet();
+ }
+ } else {
+ qDebug() << "No theme css present.";
+ m_styleSheet = baseTheme->appStyleSheet();
+ try {
+ FS::write(cssFilePath, m_styleSheet.toUtf8());
+ } catch (const Exception& e) {
+ qWarning() << "Couldn't write css:" << e.cause() << "to"
+ << cssFilePath;
+ }
+ }
+}
+
+QStringList CustomTheme::searchPaths()
+{
+ return {FS::PathCombine("themes", m_id, "resources")};
+}
+
+QString CustomTheme::id()
+{
+ return m_id;
+}
+
+QString CustomTheme::name()
+{
+ return m_name;
+}
+
+bool CustomTheme::hasColorScheme()
+{
+ return true;
+}
+
+QPalette CustomTheme::colorScheme()
+{
+ return m_palette;
+}
+
+bool CustomTheme::hasStyleSheet()
+{
+ return true;
+}
+
+QString CustomTheme::appStyleSheet()
+{
+ return m_styleSheet;
+}
+
+double CustomTheme::fadeAmount()
+{
+ return m_fadeAmount;
+}
+
+QColor CustomTheme::fadeColor()
+{
+ return m_fadeColor;
+}
+
+QString CustomTheme::qtTheme()
+{
+ return m_widgets;
+}
diff --git a/meshmc/launcher/ui/themes/CustomTheme.h b/meshmc/launcher/ui/themes/CustomTheme.h
new file mode 100644
index 0000000000..ff740c106a
--- /dev/null
+++ b/meshmc/launcher/ui/themes/CustomTheme.h
@@ -0,0 +1,51 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ITheme.h"
+
+class CustomTheme : public ITheme
+{
+ public:
+ CustomTheme(ITheme* baseTheme, QString folder);
+ virtual ~CustomTheme() {}
+
+ QString id() override;
+ QString name() override;
+ bool hasStyleSheet() override;
+ QString appStyleSheet() override;
+ bool hasColorScheme() override;
+ QPalette colorScheme() override;
+ double fadeAmount() override;
+ QColor fadeColor() override;
+ QString qtTheme() override;
+ QStringList searchPaths() override;
+
+ private: /* data */
+ QPalette m_palette;
+ QColor m_fadeColor;
+ double m_fadeAmount;
+ QString m_styleSheet;
+ QString m_name;
+ QString m_id;
+ QString m_widgets;
+};
diff --git a/meshmc/launcher/ui/themes/DarkTheme.cpp b/meshmc/launcher/ui/themes/DarkTheme.cpp
new file mode 100644
index 0000000000..08fe0e100d
--- /dev/null
+++ b/meshmc/launcher/ui/themes/DarkTheme.cpp
@@ -0,0 +1,85 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "DarkTheme.h"
+
+#include <QObject>
+
+QString DarkTheme::id()
+{
+ return "dark";
+}
+
+QString DarkTheme::name()
+{
+ return QObject::tr("Dark");
+}
+
+QString DarkTheme::tooltip()
+{
+ return QObject::tr("A dark Fusion-based theme with green accents");
+}
+
+bool DarkTheme::hasColorScheme()
+{
+ return true;
+}
+
+QPalette DarkTheme::colorScheme()
+{
+ QPalette darkPalette;
+ darkPalette.setColor(QPalette::Window, QColor(49, 49, 49));
+ darkPalette.setColor(QPalette::WindowText, Qt::white);
+ darkPalette.setColor(QPalette::Base, QColor(34, 34, 34));
+ darkPalette.setColor(QPalette::AlternateBase, QColor(49, 49, 49));
+ darkPalette.setColor(QPalette::ToolTipBase, Qt::white);
+ darkPalette.setColor(QPalette::ToolTipText, Qt::white);
+ darkPalette.setColor(QPalette::Text, Qt::white);
+ darkPalette.setColor(QPalette::Button, QColor(49, 49, 49));
+ darkPalette.setColor(QPalette::ButtonText, Qt::white);
+ darkPalette.setColor(QPalette::BrightText, Qt::red);
+ darkPalette.setColor(QPalette::Link, QColor(47, 163, 198));
+ darkPalette.setColor(QPalette::Highlight, QColor(150, 219, 89));
+ darkPalette.setColor(QPalette::HighlightedText, Qt::black);
+ darkPalette.setColor(QPalette::PlaceholderText, Qt::darkGray);
+ return fadeInactive(darkPalette, fadeAmount(), fadeColor());
+}
+
+double DarkTheme::fadeAmount()
+{
+ return 0.5;
+}
+
+QColor DarkTheme::fadeColor()
+{
+ return QColor(49, 49, 49);
+}
+
+bool DarkTheme::hasStyleSheet()
+{
+ return true;
+}
+
+QString DarkTheme::appStyleSheet()
+{
+ return "QToolTip { color: #ffffff; background-color: #2fa3c6; border: 1px "
+ "solid white; }";
+}
diff --git a/meshmc/launcher/ui/themes/DarkTheme.h b/meshmc/launcher/ui/themes/DarkTheme.h
new file mode 100644
index 0000000000..7fcc214ede
--- /dev/null
+++ b/meshmc/launcher/ui/themes/DarkTheme.h
@@ -0,0 +1,40 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "FusionTheme.h"
+
+class DarkTheme : public FusionTheme
+{
+ public:
+ virtual ~DarkTheme() {}
+
+ QString id() override;
+ QString name() override;
+ QString tooltip() override;
+ bool hasStyleSheet() override;
+ QString appStyleSheet() override;
+ bool hasColorScheme() override;
+ QPalette colorScheme() override;
+ double fadeAmount() override;
+ QColor fadeColor() override;
+};
diff --git a/meshmc/launcher/ui/themes/FusionTheme.cpp b/meshmc/launcher/ui/themes/FusionTheme.cpp
new file mode 100644
index 0000000000..7f0e1b6f19
--- /dev/null
+++ b/meshmc/launcher/ui/themes/FusionTheme.cpp
@@ -0,0 +1,27 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "FusionTheme.h"
+
+QString FusionTheme::qtTheme()
+{
+ return "Fusion";
+}
diff --git a/meshmc/launcher/ui/themes/FusionTheme.h b/meshmc/launcher/ui/themes/FusionTheme.h
new file mode 100644
index 0000000000..3b89d12bc4
--- /dev/null
+++ b/meshmc/launcher/ui/themes/FusionTheme.h
@@ -0,0 +1,32 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ITheme.h"
+
+class FusionTheme : public ITheme
+{
+ public:
+ virtual ~FusionTheme() {}
+
+ QString qtTheme() override;
+};
diff --git a/meshmc/launcher/ui/themes/ITheme.cpp b/meshmc/launcher/ui/themes/ITheme.cpp
new file mode 100644
index 0000000000..f180a98677
--- /dev/null
+++ b/meshmc/launcher/ui/themes/ITheme.cpp
@@ -0,0 +1,58 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ITheme.h"
+#include "rainbow.h"
+#include <QStyleFactory>
+#include <QDir>
+#include "Application.h"
+
+void ITheme::apply(bool)
+{
+ APPLICATION->setStyleSheet(QString());
+ QApplication::setStyle(QStyleFactory::create(qtTheme()));
+ QApplication::setPalette(colorScheme());
+ APPLICATION->setStyleSheet(appStyleSheet());
+ QDir::setSearchPaths("theme", searchPaths());
+}
+
+QPalette ITheme::fadeInactive(QPalette in, qreal bias, QColor color)
+{
+ auto blend = [&in, bias, color](QPalette::ColorRole role) {
+ QColor from = in.color(QPalette::Active, role);
+ QColor blended = Rainbow::mix(from, color, bias);
+ in.setColor(QPalette::Disabled, role, blended);
+ };
+ blend(QPalette::Window);
+ blend(QPalette::WindowText);
+ blend(QPalette::Base);
+ blend(QPalette::AlternateBase);
+ blend(QPalette::ToolTipBase);
+ blend(QPalette::ToolTipText);
+ blend(QPalette::Text);
+ blend(QPalette::Button);
+ blend(QPalette::ButtonText);
+ blend(QPalette::BrightText);
+ blend(QPalette::Link);
+ blend(QPalette::Highlight);
+ blend(QPalette::HighlightedText);
+ return in;
+}
diff --git a/meshmc/launcher/ui/themes/ITheme.h b/meshmc/launcher/ui/themes/ITheme.h
new file mode 100644
index 0000000000..56c86df823
--- /dev/null
+++ b/meshmc/launcher/ui/themes/ITheme.h
@@ -0,0 +1,60 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QString>
+#include <QPalette>
+
+class QStyle;
+
+class ITheme
+{
+ public:
+ virtual ~ITheme() {}
+ virtual void apply(bool initial);
+ virtual QString id() = 0;
+ virtual QString name() = 0;
+ virtual QString tooltip()
+ {
+ return QString();
+ }
+ virtual bool hasStyleSheet() = 0;
+ virtual QString appStyleSheet() = 0;
+ virtual QString qtTheme() = 0;
+ virtual bool hasColorScheme() = 0;
+ virtual QPalette colorScheme() = 0;
+ virtual QColor fadeColor() = 0;
+ virtual double fadeAmount() = 0;
+ virtual QStringList searchPaths()
+ {
+ return {};
+ }
+ virtual QString family()
+ {
+ return name();
+ }
+ virtual QString variant()
+ {
+ return QString();
+ }
+
+ static QPalette fadeInactive(QPalette in, qreal bias, QColor color);
+};
diff --git a/meshmc/launcher/ui/themes/SystemTheme.cpp b/meshmc/launcher/ui/themes/SystemTheme.cpp
new file mode 100644
index 0000000000..42022c7e7e
--- /dev/null
+++ b/meshmc/launcher/ui/themes/SystemTheme.cpp
@@ -0,0 +1,134 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "SystemTheme.h"
+#include <QApplication>
+#include <QStyle>
+#include <QStyleFactory>
+#include <QDebug>
+
+static const QStringList S_NATIVE_STYLES{"windows11", "windowsvista", "macos",
+ "system", "windows"};
+
+SystemTheme::SystemTheme(const QString& styleName,
+ const QPalette& defaultPalette, bool isDefaultTheme)
+{
+ m_themeName = isDefaultTheme ? "system" : styleName;
+ m_widgetTheme = styleName;
+ if (S_NATIVE_STYLES.contains(m_themeName)) {
+ m_colorPalette = defaultPalette;
+ } else {
+ // If this style matches the system's current default style, use the
+ // application palette instead of standardPalette(). standardPalette()
+ // returns a hardcoded palette that ignores the platform color scheme
+ // (e.g. Breeze on Plasma always returns a dark standardPalette even
+ // when the system is set to a light color scheme).
+ auto currentDefault = QApplication::style()->objectName();
+ if (styleName.compare(currentDefault, Qt::CaseInsensitive) == 0) {
+ m_colorPalette = defaultPalette;
+ } else {
+ auto style = QStyleFactory::create(styleName);
+ m_colorPalette =
+ style != nullptr ? style->standardPalette() : defaultPalette;
+ delete style;
+ }
+ }
+}
+
+void SystemTheme::apply(bool initial)
+{
+ if (initial && S_NATIVE_STYLES.contains(m_themeName)) {
+ return;
+ }
+ ITheme::apply(initial);
+}
+
+QString SystemTheme::id()
+{
+ return m_themeName;
+}
+
+QString SystemTheme::name()
+{
+ if (m_themeName.toLower() == "windowsvista") {
+ return QObject::tr("Windows Vista");
+ } else if (m_themeName.toLower() == "windows") {
+ return QObject::tr("Windows 9x");
+ } else if (m_themeName.toLower() == "windows11") {
+ return QObject::tr("Windows 11");
+ } else if (m_themeName.toLower() == "system") {
+ return QObject::tr("System");
+ } else {
+ return m_themeName;
+ }
+}
+
+QString SystemTheme::tooltip()
+{
+ if (m_themeName.toLower() == "windowsvista") {
+ return QObject::tr("Widget style trying to look like your win32 theme");
+ } else if (m_themeName.toLower() == "windows") {
+ return QObject::tr("Windows 9x inspired widget style");
+ } else if (m_themeName.toLower() == "windows11") {
+ return QObject::tr("WinUI 3 inspired Qt widget style");
+ } else if (m_themeName.toLower() == "fusion") {
+ return QObject::tr("The default Qt widget style");
+ } else if (m_themeName.toLower() == "system") {
+ return QObject::tr("Your current system theme");
+ } else {
+ return QString();
+ }
+}
+
+QString SystemTheme::qtTheme()
+{
+ return m_widgetTheme;
+}
+
+QPalette SystemTheme::colorScheme()
+{
+ return m_colorPalette;
+}
+
+QString SystemTheme::appStyleSheet()
+{
+ return QString();
+}
+
+double SystemTheme::fadeAmount()
+{
+ return 0.5;
+}
+
+QColor SystemTheme::fadeColor()
+{
+ return QColor(128, 128, 128);
+}
+
+bool SystemTheme::hasStyleSheet()
+{
+ return false;
+}
+
+bool SystemTheme::hasColorScheme()
+{
+ return true;
+}
diff --git a/meshmc/launcher/ui/themes/SystemTheme.h b/meshmc/launcher/ui/themes/SystemTheme.h
new file mode 100644
index 0000000000..7fed17721c
--- /dev/null
+++ b/meshmc/launcher/ui/themes/SystemTheme.h
@@ -0,0 +1,49 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ITheme.h"
+
+class SystemTheme : public ITheme
+{
+ public:
+ SystemTheme(const QString& styleName, const QPalette& defaultPalette,
+ bool isDefaultTheme);
+ virtual ~SystemTheme() {}
+ void apply(bool initial) override;
+
+ QString id() override;
+ QString name() override;
+ QString tooltip() override;
+ QString qtTheme() override;
+ bool hasStyleSheet() override;
+ QString appStyleSheet() override;
+ bool hasColorScheme() override;
+ QPalette colorScheme() override;
+ double fadeAmount() override;
+ QColor fadeColor() override;
+
+ private:
+ QPalette m_colorPalette;
+ QString m_widgetTheme;
+ QString m_themeName;
+};
diff --git a/meshmc/launcher/ui/themes/ThemeManager.cpp b/meshmc/launcher/ui/themes/ThemeManager.cpp
new file mode 100644
index 0000000000..73d0ee9330
--- /dev/null
+++ b/meshmc/launcher/ui/themes/ThemeManager.cpp
@@ -0,0 +1,369 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ThemeManager.h"
+#include "ITheme.h"
+#include "SystemTheme.h"
+#include "DarkTheme.h"
+#include "BrightTheme.h"
+#include "CustomTheme.h"
+#include "CatPack.h"
+
+#include "Application.h"
+#include "Exception.h"
+#include <QApplication>
+#include <QDebug>
+#include <QDirIterator>
+#include <QImageReader>
+#include <QSet>
+#include <QStyle>
+#include <QStyleFactory>
+#include <QSysInfo>
+#include <xdgicon.h>
+
+ThemeManager::ThemeManager()
+{
+ const auto& style = QApplication::style();
+ m_defaultStyle = style->objectName();
+ m_defaultPalette = QApplication::palette();
+
+ // Default "System" theme
+ addTheme(
+ std::make_unique<SystemTheme>(m_defaultStyle, m_defaultPalette, true));
+
+ // Built-in Fusion themes
+ auto darkTheme = new DarkTheme();
+ addTheme(std::unique_ptr<ITheme>(darkTheme));
+ addTheme(std::make_unique<BrightTheme>());
+
+ // System widget themes from QStyleFactory
+ QStringList styles = QStyleFactory::keys();
+ for (auto& st : styles) {
+#ifdef Q_OS_WINDOWS
+ if (QSysInfo::productVersion() != "11" && st == "windows11") {
+ continue;
+ }
+#endif
+ addTheme(std::make_unique<SystemTheme>(st, m_defaultPalette, false));
+ }
+
+ // Custom theme
+ addTheme(std::make_unique<CustomTheme>(darkTheme, "custom"));
+
+ initIconThemes();
+ initializeCatPacks();
+}
+
+void ThemeManager::addTheme(std::unique_ptr<ITheme> theme)
+{
+ QString id = theme->id();
+ m_themes.insert(std::make_pair(id, std::move(theme)));
+}
+
+ITheme* ThemeManager::getTheme(const QString& id)
+{
+ auto it = m_themes.find(id);
+ if (it != m_themes.end()) {
+ return it->second.get();
+ }
+ return nullptr;
+}
+
+void ThemeManager::setApplicationTheme(const QString& id, bool initial)
+{
+ auto theme = getTheme(id);
+ if (theme) {
+ theme->apply(initial);
+ } else {
+ qWarning() << "Tried to set invalid theme:" << id;
+ }
+}
+
+void ThemeManager::setIconTheme(const QString& name)
+{
+ XdgIcon::setThemeName(name);
+}
+
+void ThemeManager::applyCurrentlySelectedTheme(bool initial)
+{
+ auto settings = APPLICATION->settings();
+
+ // Apply widget theme first (sets palette)
+ auto applicationTheme = settings->get("ApplicationTheme").toString();
+ if (applicationTheme.isEmpty()) {
+ applicationTheme = "system";
+ }
+ setApplicationTheme(applicationTheme, initial);
+
+ // Auto-resolve icon variant based on the now-active palette brightness
+ auto iconTheme = settings->get("IconTheme").toString();
+ if (!iconTheme.isEmpty()) {
+ auto resolved = bestIconThemeForPalette(iconTheme);
+ if (resolved != iconTheme) {
+ settings->set("IconTheme", resolved);
+ }
+ setIconTheme(resolved);
+ }
+}
+
+std::vector<ITheme*> ThemeManager::allThemes()
+{
+ std::vector<ITheme*> ret;
+ for (auto& pair : m_themes) {
+ ret.push_back(pair.second.get());
+ }
+ return ret;
+}
+
+QStringList ThemeManager::families()
+{
+ QStringList ret;
+ QSet<QString> seen;
+ for (auto& pair : m_themes) {
+ QString fam = pair.second->family();
+ if (!seen.contains(fam)) {
+ seen.insert(fam);
+ ret.append(fam);
+ }
+ }
+ return ret;
+}
+
+std::vector<ITheme*> ThemeManager::themesInFamily(const QString& family)
+{
+ std::vector<ITheme*> ret;
+ for (auto& pair : m_themes) {
+ if (pair.second->family() == family) {
+ ret.push_back(pair.second.get());
+ }
+ }
+ return ret;
+}
+
+QList<IconThemeEntry> ThemeManager::iconThemes() const
+{
+ return m_iconThemes;
+}
+
+QStringList ThemeManager::iconThemeFamilies() const
+{
+ QStringList ret;
+ QSet<QString> seen;
+ for (const auto& entry : m_iconThemes) {
+ const QString& fam = entry.family;
+ if (!seen.contains(fam)) {
+ seen.insert(fam);
+ ret.append(fam);
+ }
+ }
+ return ret;
+}
+
+QList<IconThemeEntry>
+ThemeManager::iconThemesInFamily(const QString& family) const
+{
+ QList<IconThemeEntry> ret;
+ for (const auto& entry : m_iconThemes) {
+ if (entry.family == family) {
+ ret.append(entry);
+ }
+ }
+ return ret;
+}
+
+QString ThemeManager::resolveIconTheme(const QString& family) const
+{
+ auto entries = iconThemesInFamily(family);
+ if (entries.size() <= 1) {
+ return entries.isEmpty() ? QString() : entries[0].id;
+ }
+
+ // Check if family has variants
+ bool hasVariants = false;
+ for (const auto& entry : entries) {
+ if (!entry.variant.isEmpty()) {
+ hasVariants = true;
+ break;
+ }
+ }
+
+ if (!hasVariants) {
+ return entries[0].id;
+ }
+
+ // Auto-detect based on current palette brightness
+ auto windowColor = QApplication::palette().color(QPalette::Window);
+ bool isDark = windowColor.lightnessF() < 0.5;
+
+ for (const auto& entry : entries) {
+ QString v = entry.variant.toLower();
+ if (isDark && v == "dark")
+ return entry.id;
+ if (!isDark && v == "light")
+ return entry.id;
+ }
+
+ return entries[0].id;
+}
+
+QString
+ThemeManager::bestIconThemeForPalette(const QString& currentIconId) const
+{
+ // Find the family of the current icon theme
+ QString family;
+ for (const auto& entry : m_iconThemes) {
+ if (entry.id == currentIconId) {
+ family = entry.family;
+ break;
+ }
+ }
+
+ if (family.isEmpty()) {
+ return currentIconId;
+ }
+
+ // Resolve the best variant for that family based on current palette
+ QString resolved = resolveIconTheme(family);
+ return resolved.isEmpty() ? currentIconId : resolved;
+}
+
+void ThemeManager::initIconThemes()
+{
+ m_iconThemes = {
+ {"pe_colored", QObject::tr("Default"), QObject::tr("Default"),
+ QString()},
+ {"multimc", QStringLiteral("MultiMC"), QStringLiteral("MultiMC"),
+ QString()},
+ {"pe_dark", QObject::tr("Simple (Dark Icons)"),
+ QObject::tr("Simple (Dark Icons)"), QString()},
+ {"pe_light", QObject::tr("Simple (Light Icons)"),
+ QObject::tr("Simple (Light Icons)"), QString()},
+ {"pe_blue", QObject::tr("Simple (Blue Icons)"),
+ QObject::tr("Simple (Blue Icons)"), QString()},
+ {"pe_colored", QObject::tr("Simple (Colored Icons)"),
+ QObject::tr("Simple (Colored Icons)"), QString()},
+ {"OSX", QStringLiteral("OSX"), QStringLiteral("OSX"), QString()},
+ {"iOS", QStringLiteral("iOS"), QStringLiteral("iOS"), QString()},
+ {"flat", QStringLiteral("Flat Light"), QStringLiteral("Flat"),
+ QObject::tr("Light")},
+ {"flat_white", QStringLiteral("Flat Dark"), QStringLiteral("Flat"),
+ QObject::tr("Dark")},
+ {"breeze_dark", QStringLiteral("Breeze Dark"), QStringLiteral("Breeze"),
+ QObject::tr("Dark")},
+ {"breeze_light", QStringLiteral("Breeze Light"),
+ QStringLiteral("Breeze"), QObject::tr("Light")},
+ {"custom", QObject::tr("Custom"), QObject::tr("Custom"), QString()},
+ };
+}
+
+void ThemeManager::initializeCatPacks()
+{
+ QList<std::pair<QString, QString>> defaultCats{
+ {"kitteh", QObject::tr("Background Cat (from MultiMC)")},
+ {"rory", QObject::tr("Rory ID 11 (drawn by Ashtaka)")},
+ {"rory-flat",
+ QObject::tr("Rory ID 11 (flat edition, drawn by Ashtaka)")},
+ {"teawie", QObject::tr("Teawie (drawn by SympathyTea)")}};
+
+ for (const auto& [id, name] : defaultCats) {
+ addCatPack(std::make_unique<BasicCatPack>(id, name));
+ }
+
+ // Create catpacks folder in data directory
+ m_catPacksFolder = QDir("catpacks");
+ if (!m_catPacksFolder.mkpath("."))
+ qWarning() << "Couldn't create catpacks folder";
+
+ QStringList supportedImageFormats;
+ for (const auto& format : QImageReader::supportedImageFormats()) {
+ supportedImageFormats.append("*." + format);
+ }
+
+ auto loadFiles = [this, &supportedImageFormats](const QDir& dir) {
+ QDirIterator it(dir.absolutePath(), supportedImageFormats, QDir::Files);
+ while (it.hasNext()) {
+ QFileInfo info(it.next());
+ addCatPack(std::make_unique<FileCatPack>(info));
+ }
+ };
+
+ // Load image files in catpacks folder root
+ loadFiles(m_catPacksFolder);
+
+ // Load subdirectories
+ QDirIterator directoryIterator(m_catPacksFolder.path(),
+ QDir::Dirs | QDir::NoDotAndDotDot);
+ while (directoryIterator.hasNext()) {
+ QDir dir(directoryIterator.next());
+ QFileInfo manifest(dir.absoluteFilePath("catpack.json"));
+
+ if (manifest.isFile()) {
+ try {
+ addCatPack(std::make_unique<JsonCatPack>(manifest));
+ } catch (const Exception& e) {
+ qWarning() << "Couldn't load catpack json:" << e.cause();
+ }
+ } else {
+ loadFiles(dir);
+ }
+ }
+}
+
+void ThemeManager::addCatPack(std::unique_ptr<CatPack> catPack)
+{
+ QString id = catPack->id();
+ if (m_catPacks.find(id) == m_catPacks.end())
+ m_catPacks.emplace(id, std::move(catPack));
+ else
+ qWarning() << "CatPack" << id << "not added to prevent id duplication";
+}
+
+QString ThemeManager::getCatPack(const QString& catName)
+{
+ QString id = catName.isEmpty()
+ ? APPLICATION->settings()->get("BackgroundCat").toString()
+ : catName;
+
+ auto it = m_catPacks.find(id);
+ if (it != m_catPacks.end())
+ return it->second->path();
+
+ // Fallback to first available
+ if (!m_catPacks.empty())
+ return m_catPacks.begin()->second->path();
+
+ return QString();
+}
+
+QList<CatPack*> ThemeManager::getValidCatPacks()
+{
+ QList<CatPack*> ret;
+ ret.reserve(m_catPacks.size());
+ for (auto& [id, pack] : m_catPacks) {
+ ret.append(pack.get());
+ }
+ return ret;
+}
+
+QDir ThemeManager::getCatPacksFolder()
+{
+ return m_catPacksFolder;
+}
diff --git a/meshmc/launcher/ui/themes/ThemeManager.h b/meshmc/launcher/ui/themes/ThemeManager.h
new file mode 100644
index 0000000000..2b8cdc5c1a
--- /dev/null
+++ b/meshmc/launcher/ui/themes/ThemeManager.h
@@ -0,0 +1,91 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QString>
+#include <QList>
+#include <QDir>
+#include <QIcon>
+#include <QPalette>
+#include <memory>
+#include <map>
+#include <vector>
+
+class ITheme;
+
+#include "CatPack.h"
+
+struct IconThemeEntry {
+ QString id;
+ QString name;
+ QString family;
+ QString variant;
+};
+
+class ThemeManager
+{
+ public:
+ ThemeManager();
+
+ void addTheme(std::unique_ptr<ITheme> theme);
+
+ ITheme* getTheme(const QString& id);
+
+ void setApplicationTheme(const QString& id, bool initial);
+
+ void setIconTheme(const QString& name);
+
+ void applyCurrentlySelectedTheme(bool initial = false);
+
+ std::vector<ITheme*> allThemes();
+
+ QStringList families();
+
+ std::vector<ITheme*> themesInFamily(const QString& family);
+
+ QList<IconThemeEntry> iconThemes() const;
+
+ QStringList iconThemeFamilies() const;
+
+ QList<IconThemeEntry> iconThemesInFamily(const QString& family) const;
+
+ QString resolveIconTheme(const QString& family) const;
+
+ QString bestIconThemeForPalette(const QString& currentIconId) const;
+
+ // CatPack API
+ QString getCatPack(const QString& catName = QString());
+ QList<CatPack*> getValidCatPacks();
+ QDir getCatPacksFolder();
+
+ private:
+ std::map<QString, std::unique_ptr<ITheme>> m_themes;
+ QList<IconThemeEntry> m_iconThemes;
+ std::map<QString, std::unique_ptr<CatPack>> m_catPacks;
+ QDir m_catPacksFolder;
+ QString m_defaultStyle;
+ QPalette m_defaultPalette;
+
+ void initIconThemes();
+ void initializeCatPacks();
+ void addCatPack(std::unique_ptr<CatPack> catPack);
+};
diff --git a/meshmc/launcher/ui/widgets/Common.cpp b/meshmc/launcher/ui/widgets/Common.cpp
new file mode 100644
index 0000000000..abbcc67cb7
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/Common.cpp
@@ -0,0 +1,47 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "Common.h"
+
+// Origin: Qt
+QStringList viewItemTextLayout(QTextLayout& textLayout, int lineWidth,
+ qreal& height, qreal& widthUsed)
+{
+ QStringList lines;
+ height = 0;
+ widthUsed = 0;
+ textLayout.beginLayout();
+ QString str = textLayout.text();
+ while (true) {
+ QTextLine line = textLayout.createLine();
+ if (!line.isValid())
+ break;
+ if (line.textLength() == 0)
+ break;
+ line.setLineWidth(lineWidth);
+ line.setPosition(QPointF(0, height));
+ height += line.height();
+ lines.append(str.mid(line.textStart(), line.textLength()));
+ widthUsed = qMax(widthUsed, line.naturalTextWidth());
+ }
+ textLayout.endLayout();
+ return lines;
+}
diff --git a/meshmc/launcher/ui/widgets/Common.h b/meshmc/launcher/ui/widgets/Common.h
new file mode 100644
index 0000000000..d1f08ee7e7
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/Common.h
@@ -0,0 +1,27 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QStringList>
+#include <QTextLayout>
+
+QStringList viewItemTextLayout(QTextLayout& textLayout, int lineWidth,
+ qreal& height, qreal& widthUsed); \ No newline at end of file
diff --git a/meshmc/launcher/ui/widgets/CustomCommands.cpp b/meshmc/launcher/ui/widgets/CustomCommands.cpp
new file mode 100644
index 0000000000..6356f3804e
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/CustomCommands.cpp
@@ -0,0 +1,69 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "CustomCommands.h"
+#include "ui_CustomCommands.h"
+
+CustomCommands::~CustomCommands()
+{
+ delete ui;
+}
+
+CustomCommands::CustomCommands(QWidget* parent)
+ : QWidget(parent), ui(new Ui::CustomCommands)
+{
+ ui->setupUi(this);
+}
+
+void CustomCommands::initialize(bool checkable, bool checked,
+ const QString& prelaunch,
+ const QString& wrapper, const QString& postexit)
+{
+ ui->customCommandsGroupBox->setCheckable(checkable);
+ if (checkable) {
+ ui->customCommandsGroupBox->setChecked(checked);
+ }
+ ui->preLaunchCmdTextBox->setText(prelaunch);
+ ui->wrapperCmdTextBox->setText(wrapper);
+ ui->postExitCmdTextBox->setText(postexit);
+}
+
+bool CustomCommands::checked() const
+{
+ if (!ui->customCommandsGroupBox->isCheckable())
+ return true;
+ return ui->customCommandsGroupBox->isChecked();
+}
+
+QString CustomCommands::prelaunchCommand() const
+{
+ return ui->preLaunchCmdTextBox->text();
+}
+
+QString CustomCommands::wrapperCommand() const
+{
+ return ui->wrapperCmdTextBox->text();
+}
+
+QString CustomCommands::postexitCommand() const
+{
+ return ui->postExitCmdTextBox->text();
+}
diff --git a/meshmc/launcher/ui/widgets/CustomCommands.h b/meshmc/launcher/ui/widgets/CustomCommands.h
new file mode 100644
index 0000000000..7da48221ca
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/CustomCommands.h
@@ -0,0 +1,65 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 2018-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.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+namespace Ui
+{
+ class CustomCommands;
+}
+
+class CustomCommands : public QWidget
+{
+ Q_OBJECT
+
+ public:
+ explicit CustomCommands(QWidget* parent = 0);
+ virtual ~CustomCommands();
+ void initialize(bool checkable, bool checked, const QString& prelaunch,
+ const QString& wrapper, const QString& postexit);
+
+ bool checked() const;
+ QString prelaunchCommand() const;
+ QString wrapperCommand() const;
+ QString postexitCommand() const;
+
+ private:
+ Ui::CustomCommands* ui;
+};
diff --git a/meshmc/launcher/ui/widgets/CustomCommands.ui b/meshmc/launcher/ui/widgets/CustomCommands.ui
new file mode 100644
index 0000000000..a5d27faf83
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/CustomCommands.ui
@@ -0,0 +1,107 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>CustomCommands</class>
+ <widget class="QWidget" name="CustomCommands">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>518</width>
+ <height>646</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QGroupBox" name="customCommandsGroupBox">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="title">
+ <string>Cus&amp;tom Commands</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_4">
+ <item row="2" column="0">
+ <widget class="QLabel" name="labelPostExitCmd">
+ <property name="text">
+ <string>Post-exit command:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QLineEdit" name="preLaunchCmdTextBox"/>
+ </item>
+ <item row="0" column="0">
+ <widget class="QLabel" name="labelPreLaunchCmd">
+ <property name="text">
+ <string>Pre-launch command:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <widget class="QLineEdit" name="postExitCmdTextBox"/>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="labelWrapperCmd">
+ <property name="text">
+ <string>Wrapper command:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLineEdit" name="wrapperCmdTextBox"/>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="labelCustomCmdsDescription">
+ <property name="text">
+ <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Pre-launch command runs before the instance launches and post-exit command runs after it exits.&lt;/p&gt;&lt;p&gt;Both will be run in MeshMC's working folder with extra environment variables:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;$INST_NAME - Name of the instance&lt;/li&gt;&lt;li&gt;$INST_ID - ID of the instance (its folder name)&lt;/li&gt;&lt;li&gt;$INST_DIR - absolute path of the instance&lt;/li&gt;&lt;li&gt;$INST_MC_DIR - absolute path of minecraft&lt;/li&gt;&lt;li&gt;$INST_JAVA - java binary used for launch&lt;/li&gt;&lt;li&gt;$INST_JAVA_ARGS - command-line parameters used for launch&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Wrapper command allows launching using an extra wrapper program (like 'optirun' on Linux)&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/widgets/DropLabel.cpp b/meshmc/launcher/ui/widgets/DropLabel.cpp
new file mode 100644
index 0000000000..abc7570168
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/DropLabel.cpp
@@ -0,0 +1,61 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "DropLabel.h"
+
+#include <QMimeData>
+#include <QDropEvent>
+
+DropLabel::DropLabel(QWidget* parent) : QLabel(parent)
+{
+ setAcceptDrops(true);
+}
+
+void DropLabel::dragEnterEvent(QDragEnterEvent* event)
+{
+ event->acceptProposedAction();
+}
+
+void DropLabel::dragMoveEvent(QDragMoveEvent* event)
+{
+ event->acceptProposedAction();
+}
+
+void DropLabel::dragLeaveEvent(QDragLeaveEvent* event)
+{
+ event->accept();
+}
+
+void DropLabel::dropEvent(QDropEvent* event)
+{
+ const QMimeData* mimeData = event->mimeData();
+
+ if (!mimeData) {
+ return;
+ }
+
+ if (mimeData->hasUrls()) {
+ auto urls = mimeData->urls();
+ emit droppedURLs(urls);
+ }
+
+ event->acceptProposedAction();
+}
diff --git a/meshmc/launcher/ui/widgets/DropLabel.h b/meshmc/launcher/ui/widgets/DropLabel.h
new file mode 100644
index 0000000000..a3cef5af31
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/DropLabel.h
@@ -0,0 +1,41 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QLabel>
+
+class DropLabel : public QLabel
+{
+ Q_OBJECT
+
+ public:
+ explicit DropLabel(QWidget* parent = nullptr);
+
+ signals:
+ void droppedURLs(QList<QUrl> urls);
+
+ protected:
+ void dropEvent(QDropEvent* event) override;
+ void dragEnterEvent(QDragEnterEvent* event) override;
+ void dragMoveEvent(QDragMoveEvent* event) override;
+ void dragLeaveEvent(QDragLeaveEvent* event) override;
+};
diff --git a/meshmc/launcher/ui/widgets/ErrorFrame.cpp b/meshmc/launcher/ui/widgets/ErrorFrame.cpp
new file mode 100644
index 0000000000..eb786149bc
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/ErrorFrame.cpp
@@ -0,0 +1,142 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 <QMessageBox>
+#include <QtGui>
+
+#include "ErrorFrame.h"
+#include "ui_ErrorFrame.h"
+
+#include "ui/dialogs/CustomMessageBox.h"
+
+void ErrorFrame::clear()
+{
+ setTitle(QString());
+ setDescription(QString());
+}
+
+ErrorFrame::ErrorFrame(QWidget* parent) : QFrame(parent), ui(new Ui::ErrorFrame)
+{
+ ui->setupUi(this);
+ ui->label_Description->setHidden(true);
+ ui->label_Title->setHidden(true);
+ updateHiddenState();
+}
+
+ErrorFrame::~ErrorFrame()
+{
+ delete ui;
+}
+
+void ErrorFrame::updateHiddenState()
+{
+ if (ui->label_Description->isHidden() && ui->label_Title->isHidden()) {
+ setHidden(true);
+ } else {
+ setHidden(false);
+ }
+}
+
+void ErrorFrame::setTitle(QString text)
+{
+ if (text.isEmpty()) {
+ ui->label_Title->setHidden(true);
+ } else {
+ ui->label_Title->setText(text);
+ ui->label_Title->setHidden(false);
+ }
+ updateHiddenState();
+}
+
+void ErrorFrame::setDescription(QString text)
+{
+ if (text.isEmpty()) {
+ ui->label_Description->setHidden(true);
+ updateHiddenState();
+ return;
+ } else {
+ ui->label_Description->setHidden(false);
+ updateHiddenState();
+ }
+ ui->label_Description->setToolTip("");
+ QString intermediatetext = text.trimmed();
+ bool prev(false);
+ QChar rem('\n');
+ QString finaltext;
+ finaltext.reserve(intermediatetext.size());
+ foreach (const QChar& c, intermediatetext) {
+ if (c == rem && prev) {
+ continue;
+ }
+ prev = c == rem;
+ finaltext += c;
+ }
+ QString labeltext;
+ labeltext.reserve(300);
+ if (finaltext.length() > 290) {
+ ui->label_Description->setOpenExternalLinks(false);
+ ui->label_Description->setTextFormat(Qt::TextFormat::RichText);
+ desc = text;
+ // This allows injecting HTML here.
+ labeltext.append("<html><body>" + finaltext.left(287) +
+ "<a href=\"#mod_desc\">...</a></body></html>");
+ QObject::connect(ui->label_Description, &QLabel::linkActivated, this,
+ &ErrorFrame::ellipsisHandler);
+ } else {
+ ui->label_Description->setTextFormat(Qt::TextFormat::PlainText);
+ labeltext.append(finaltext);
+ }
+ ui->label_Description->setText(labeltext);
+}
+
+void ErrorFrame::ellipsisHandler(const QString& link)
+{
+ if (!currentBox) {
+ currentBox = CustomMessageBox::selectable(this, QString(), desc);
+ connect(currentBox, &QMessageBox::finished, this,
+ &ErrorFrame::boxClosed);
+ currentBox->show();
+ } else {
+ currentBox->setText(desc);
+ }
+}
+
+void ErrorFrame::boxClosed(int result)
+{
+ currentBox = nullptr;
+}
diff --git a/meshmc/launcher/ui/widgets/ErrorFrame.h b/meshmc/launcher/ui/widgets/ErrorFrame.h
new file mode 100644
index 0000000000..e1d94e2a89
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/ErrorFrame.h
@@ -0,0 +1,72 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QFrame>
+
+namespace Ui
+{
+ class ErrorFrame;
+}
+
+class ErrorFrame : public QFrame
+{
+ Q_OBJECT
+
+ public:
+ explicit ErrorFrame(QWidget* parent = 0);
+ ~ErrorFrame();
+
+ void setTitle(QString text);
+ void setDescription(QString text);
+
+ void clear();
+
+ public slots:
+ void ellipsisHandler(const QString& link);
+ void boxClosed(int result);
+
+ private:
+ void updateHiddenState();
+
+ private:
+ Ui::ErrorFrame* ui;
+ QString desc;
+ class QMessageBox* currentBox = nullptr;
+};
diff --git a/meshmc/launcher/ui/widgets/ErrorFrame.ui b/meshmc/launcher/ui/widgets/ErrorFrame.ui
new file mode 100644
index 0000000000..0bb5674395
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/ErrorFrame.ui
@@ -0,0 +1,92 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ErrorFrame</class>
+ <widget class="QFrame" name="ErrorFrame">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>527</width>
+ <height>113</height>
+ </rect>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Minimum">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="maximumSize">
+ <size>
+ <width>16777215</width>
+ <height>120</height>
+ </size>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <property name="spacing">
+ <number>6</number>
+ </property>
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QLabel" name="label_Title">
+ <property name="text">
+ <string notr="true"/>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::RichText</enum>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ <property name="openExternalLinks">
+ <bool>true</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="label_Description">
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="text">
+ <string notr="true"/>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::RichText</enum>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ <property name="openExternalLinks">
+ <bool>true</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/widgets/FocusLineEdit.cpp b/meshmc/launcher/ui/widgets/FocusLineEdit.cpp
new file mode 100644
index 0000000000..18db9ba3e1
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/FocusLineEdit.cpp
@@ -0,0 +1,45 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "FocusLineEdit.h"
+#include <QDebug>
+
+FocusLineEdit::FocusLineEdit(QWidget* parent) : QLineEdit(parent)
+{
+ _selectOnMousePress = false;
+}
+
+void FocusLineEdit::focusInEvent(QFocusEvent* e)
+{
+ QLineEdit::focusInEvent(e);
+ selectAll();
+ _selectOnMousePress = true;
+}
+
+void FocusLineEdit::mousePressEvent(QMouseEvent* me)
+{
+ QLineEdit::mousePressEvent(me);
+ if (_selectOnMousePress) {
+ selectAll();
+ _selectOnMousePress = false;
+ }
+ qDebug() << selectedText();
+}
diff --git a/meshmc/launcher/ui/widgets/FocusLineEdit.h b/meshmc/launcher/ui/widgets/FocusLineEdit.h
new file mode 100644
index 0000000000..024811b6d5
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/FocusLineEdit.h
@@ -0,0 +1,36 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <QLineEdit>
+
+class FocusLineEdit : public QLineEdit
+{
+ Q_OBJECT
+ public:
+ FocusLineEdit(QWidget* parent);
+ virtual ~FocusLineEdit() {}
+
+ protected:
+ void focusInEvent(QFocusEvent* e);
+ void mousePressEvent(QMouseEvent* me);
+
+ bool _selectOnMousePress;
+};
diff --git a/meshmc/launcher/ui/widgets/IconLabel.cpp b/meshmc/launcher/ui/widgets/IconLabel.cpp
new file mode 100644
index 0000000000..11de955257
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/IconLabel.cpp
@@ -0,0 +1,61 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "IconLabel.h"
+
+#include <QStyle>
+#include <QStyleOption>
+#include <QLayout>
+#include <QPainter>
+#include <QRect>
+
+IconLabel::IconLabel(QWidget* parent, QIcon icon, QSize size)
+ : QWidget(parent), m_size(size), m_icon(icon)
+{
+ setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
+}
+
+QSize IconLabel::sizeHint() const
+{
+ return m_size;
+}
+
+void IconLabel::setIcon(QIcon icon)
+{
+ m_icon = icon;
+ update();
+}
+
+void IconLabel::paintEvent(QPaintEvent*)
+{
+ QPainter p(this);
+ QRect rect = contentsRect();
+ int width = rect.width();
+ int height = rect.height();
+ if (width < height) {
+ rect.setHeight(width);
+ rect.translate(0, (height - width) / 2);
+ } else if (width > height) {
+ rect.setWidth(height);
+ rect.translate((width - height) / 2, 0);
+ }
+ m_icon.paint(&p, rect);
+}
diff --git a/meshmc/launcher/ui/widgets/IconLabel.h b/meshmc/launcher/ui/widgets/IconLabel.h
new file mode 100644
index 0000000000..e7792d3da9
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/IconLabel.h
@@ -0,0 +1,47 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QWidget>
+#include <QIcon>
+
+class QStyleOption;
+
+/**
+ * This is a trivial widget that paints a QIcon of the specified size.
+ */
+class IconLabel : public QWidget
+{
+ Q_OBJECT
+
+ public:
+ /// Create a line separator. orientation is the orientation of the line.
+ explicit IconLabel(QWidget* parent, QIcon icon, QSize size);
+
+ virtual QSize sizeHint() const;
+ virtual void paintEvent(QPaintEvent*);
+
+ void setIcon(QIcon icon);
+
+ private:
+ QSize m_size;
+ QIcon m_icon;
+};
diff --git a/meshmc/launcher/ui/widgets/InstanceCardWidget.ui b/meshmc/launcher/ui/widgets/InstanceCardWidget.ui
new file mode 100644
index 0000000000..6eeeb07692
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/InstanceCardWidget.ui
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>InstanceCardWidget</class>
+ <widget class="QWidget" name="InstanceCardWidget">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>473</width>
+ <height>118</height>
+ </rect>
+ </property>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="0" column="0" rowspan="2">
+ <widget class="QToolButton" name="iconButton">
+ <property name="iconSize">
+ <size>
+ <width>80</width>
+ <height>80</height>
+ </size>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QLabel" name="nameLabel">
+ <property name="text">
+ <string>&amp;Name:</string>
+ </property>
+ <property name="buddy">
+ <cstring>instNameTextBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="2">
+ <widget class="QLineEdit" name="instNameTextBox"/>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLabel" name="groupLabel">
+ <property name="text">
+ <string>&amp;Group:</string>
+ </property>
+ <property name="buddy">
+ <cstring>groupBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="2">
+ <widget class="QComboBox" name="groupBox">
+ <property name="editable">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/widgets/JavaSettingsWidget.cpp b/meshmc/launcher/ui/widgets/JavaSettingsWidget.cpp
new file mode 100644
index 0000000000..731f0166d7
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/JavaSettingsWidget.cpp
@@ -0,0 +1,438 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "JavaSettingsWidget.h"
+
+#include <QVBoxLayout>
+#include <QGroupBox>
+#include <QSpinBox>
+#include <QLabel>
+#include <QLineEdit>
+#include <QPushButton>
+#include <QToolButton>
+#include <QFileDialog>
+
+#include <sys.h>
+
+#include "java/JavaInstall.h"
+#include "java/JavaUtils.h"
+#include "FileSystem.h"
+
+#include "ui/dialogs/CustomMessageBox.h"
+#include "ui/widgets/VersionSelectWidget.h"
+
+#include "Application.h"
+#include "BuildConfig.h"
+
+JavaSettingsWidget::JavaSettingsWidget(QWidget* parent) : QWidget(parent)
+{
+ m_availableMemory = Sys::getSystemRam() / Sys::mebibyte;
+
+ goodIcon = APPLICATION->getThemedIcon("status-good");
+ yellowIcon = APPLICATION->getThemedIcon("status-yellow");
+ badIcon = APPLICATION->getThemedIcon("status-bad");
+ setupUi();
+
+ connect(m_minMemSpinBox, SIGNAL(valueChanged(int)), this,
+ SLOT(memoryValueChanged(int)));
+ connect(m_maxMemSpinBox, SIGNAL(valueChanged(int)), this,
+ SLOT(memoryValueChanged(int)));
+ connect(m_permGenSpinBox, SIGNAL(valueChanged(int)), this,
+ SLOT(memoryValueChanged(int)));
+ connect(m_versionWidget, &VersionSelectWidget::selectedVersionChanged, this,
+ &JavaSettingsWidget::javaVersionSelected);
+ connect(m_javaBrowseBtn, &QPushButton::clicked, this,
+ &JavaSettingsWidget::on_javaBrowseBtn_clicked);
+ connect(m_javaPathTextBox, &QLineEdit::textEdited, this,
+ &JavaSettingsWidget::javaPathEdited);
+ connect(m_javaStatusBtn, &QToolButton::clicked, this,
+ &JavaSettingsWidget::on_javaStatusBtn_clicked);
+}
+
+void JavaSettingsWidget::setupUi()
+{
+ setObjectName(QStringLiteral("javaSettingsWidget"));
+ m_verticalLayout = new QVBoxLayout(this);
+ m_verticalLayout->setObjectName(QStringLiteral("verticalLayout"));
+
+ m_versionWidget = new VersionSelectWidget(this);
+ m_verticalLayout->addWidget(m_versionWidget);
+
+ m_horizontalLayout = new QHBoxLayout();
+ m_horizontalLayout->setObjectName(QStringLiteral("horizontalLayout"));
+ m_javaPathTextBox = new QLineEdit(this);
+ m_javaPathTextBox->setObjectName(QStringLiteral("javaPathTextBox"));
+
+ m_horizontalLayout->addWidget(m_javaPathTextBox);
+
+ m_javaBrowseBtn = new QPushButton(this);
+ m_javaBrowseBtn->setObjectName(QStringLiteral("javaBrowseBtn"));
+
+ m_horizontalLayout->addWidget(m_javaBrowseBtn);
+
+ m_javaStatusBtn = new QToolButton(this);
+ m_javaStatusBtn->setIcon(yellowIcon);
+ m_horizontalLayout->addWidget(m_javaStatusBtn);
+
+ m_verticalLayout->addLayout(m_horizontalLayout);
+
+ m_memoryGroupBox = new QGroupBox(this);
+ m_memoryGroupBox->setObjectName(QStringLiteral("memoryGroupBox"));
+ m_gridLayout_2 = new QGridLayout(m_memoryGroupBox);
+ m_gridLayout_2->setObjectName(QStringLiteral("gridLayout_2"));
+
+ m_labelMinMem = new QLabel(m_memoryGroupBox);
+ m_labelMinMem->setObjectName(QStringLiteral("labelMinMem"));
+ m_gridLayout_2->addWidget(m_labelMinMem, 0, 0, 1, 1);
+
+ m_minMemSpinBox = new QSpinBox(m_memoryGroupBox);
+ m_minMemSpinBox->setObjectName(QStringLiteral("minMemSpinBox"));
+ m_minMemSpinBox->setSuffix(QStringLiteral(" MiB"));
+ m_minMemSpinBox->setMinimum(128);
+ m_minMemSpinBox->setMaximum(m_availableMemory);
+ m_minMemSpinBox->setSingleStep(128);
+ m_labelMinMem->setBuddy(m_minMemSpinBox);
+ m_gridLayout_2->addWidget(m_minMemSpinBox, 0, 1, 1, 1);
+
+ m_labelMaxMem = new QLabel(m_memoryGroupBox);
+ m_labelMaxMem->setObjectName(QStringLiteral("labelMaxMem"));
+ m_gridLayout_2->addWidget(m_labelMaxMem, 1, 0, 1, 1);
+
+ m_maxMemSpinBox = new QSpinBox(m_memoryGroupBox);
+ m_maxMemSpinBox->setObjectName(QStringLiteral("maxMemSpinBox"));
+ m_maxMemSpinBox->setSuffix(QStringLiteral(" MiB"));
+ m_maxMemSpinBox->setMinimum(128);
+ m_maxMemSpinBox->setMaximum(m_availableMemory);
+ m_maxMemSpinBox->setSingleStep(128);
+ m_labelMaxMem->setBuddy(m_maxMemSpinBox);
+ m_gridLayout_2->addWidget(m_maxMemSpinBox, 1, 1, 1, 1);
+
+ m_labelPermGen = new QLabel(m_memoryGroupBox);
+ m_labelPermGen->setObjectName(QStringLiteral("labelPermGen"));
+ m_labelPermGen->setText(QStringLiteral("PermGen:"));
+ m_gridLayout_2->addWidget(m_labelPermGen, 2, 0, 1, 1);
+ m_labelPermGen->setVisible(false);
+
+ m_permGenSpinBox = new QSpinBox(m_memoryGroupBox);
+ m_permGenSpinBox->setObjectName(QStringLiteral("permGenSpinBox"));
+ m_permGenSpinBox->setSuffix(QStringLiteral(" MiB"));
+ m_permGenSpinBox->setMinimum(64);
+ m_permGenSpinBox->setMaximum(m_availableMemory);
+ m_permGenSpinBox->setSingleStep(8);
+ m_gridLayout_2->addWidget(m_permGenSpinBox, 2, 1, 1, 1);
+ m_permGenSpinBox->setVisible(false);
+
+ m_verticalLayout->addWidget(m_memoryGroupBox);
+
+ retranslate();
+}
+
+void JavaSettingsWidget::initialize()
+{
+ m_versionWidget->initialize(APPLICATION->javalist().get());
+ m_versionWidget->setResizeOn(2);
+ auto s = APPLICATION->settings();
+ // Memory
+ observedMinMemory = s->get("MinMemAlloc").toInt();
+ observedMaxMemory = s->get("MaxMemAlloc").toInt();
+ observedPermGenMemory = s->get("PermGen").toInt();
+ m_minMemSpinBox->setValue(observedMinMemory);
+ m_maxMemSpinBox->setValue(observedMaxMemory);
+ m_permGenSpinBox->setValue(observedPermGenMemory);
+}
+
+void JavaSettingsWidget::refresh()
+{
+ m_versionWidget->loadList();
+}
+
+JavaSettingsWidget::ValidationStatus JavaSettingsWidget::validate()
+{
+ switch (javaStatus) {
+ default:
+ case JavaStatus::NotSet:
+ case JavaStatus::DoesNotExist:
+ case JavaStatus::DoesNotStart:
+ case JavaStatus::ReturnedInvalidData: {
+ int button =
+ CustomMessageBox::selectable(
+ this, tr("No Java version selected"),
+ tr("You didn't select a Java version or selected something "
+ "that doesn't work.\n"
+ "%1 will not be able to start Minecraft.\n"
+ "Do you wish to proceed without any Java?"
+ "\n\n"
+ "You can change the Java version in the settings "
+ "later.\n")
+ .arg(BuildConfig.MESHMC_NAME),
+ QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No,
+ QMessageBox::NoButton)
+ ->exec();
+ if (button == QMessageBox::No) {
+ return ValidationStatus::Bad;
+ }
+ return ValidationStatus::JavaBad;
+ } break;
+ case JavaStatus::Pending: {
+ return ValidationStatus::Bad;
+ }
+ case JavaStatus::Good: {
+ return ValidationStatus::AllOK;
+ }
+ }
+}
+
+QString JavaSettingsWidget::javaPath() const
+{
+ return m_javaPathTextBox->text();
+}
+
+int JavaSettingsWidget::maxHeapSize() const
+{
+ return m_maxMemSpinBox->value();
+}
+
+int JavaSettingsWidget::minHeapSize() const
+{
+ return m_minMemSpinBox->value();
+}
+
+bool JavaSettingsWidget::permGenEnabled() const
+{
+ return m_permGenSpinBox->isVisible();
+}
+
+int JavaSettingsWidget::permGenSize() const
+{
+ return m_permGenSpinBox->value();
+}
+
+void JavaSettingsWidget::memoryValueChanged(int)
+{
+ bool actuallyChanged = false;
+ int min = m_minMemSpinBox->value();
+ int max = m_maxMemSpinBox->value();
+ int permgen = m_permGenSpinBox->value();
+ QObject* obj = sender();
+ if (obj == m_minMemSpinBox && min != observedMinMemory) {
+ observedMinMemory = min;
+ actuallyChanged = true;
+ if (min > max) {
+ observedMaxMemory = min;
+ m_maxMemSpinBox->setValue(min);
+ }
+ } else if (obj == m_maxMemSpinBox && max != observedMaxMemory) {
+ observedMaxMemory = max;
+ actuallyChanged = true;
+ if (min > max) {
+ observedMinMemory = max;
+ m_minMemSpinBox->setValue(max);
+ }
+ } else if (obj == m_permGenSpinBox && permgen != observedPermGenMemory) {
+ observedPermGenMemory = permgen;
+ actuallyChanged = true;
+ }
+ if (actuallyChanged) {
+ checkJavaPathOnEdit(m_javaPathTextBox->text());
+ }
+}
+
+void JavaSettingsWidget::javaVersionSelected(BaseVersionPtr version)
+{
+ auto java = std::dynamic_pointer_cast<JavaInstall>(version);
+ if (!java) {
+ return;
+ }
+ auto visible = java->id.requiresPermGen();
+ m_labelPermGen->setVisible(visible);
+ m_permGenSpinBox->setVisible(visible);
+ m_javaPathTextBox->setText(java->path);
+ checkJavaPath(java->path);
+}
+
+void JavaSettingsWidget::on_javaBrowseBtn_clicked()
+{
+ QString filter;
+#if defined Q_OS_WIN32
+ filter = "Java (javaw.exe)";
+#else
+ filter = "Java (java)";
+#endif
+ QString raw_path = QFileDialog::getOpenFileName(
+ this, tr("Find Java executable"), QString(), filter);
+ if (raw_path.isEmpty()) {
+ return;
+ }
+ QString cooked_path = FS::NormalizePath(raw_path);
+ m_javaPathTextBox->setText(cooked_path);
+ checkJavaPath(cooked_path);
+}
+
+void JavaSettingsWidget::on_javaStatusBtn_clicked()
+{
+ QString text;
+ bool failed = false;
+ switch (javaStatus) {
+ case JavaStatus::NotSet:
+ checkJavaPath(m_javaPathTextBox->text());
+ return;
+ case JavaStatus::DoesNotExist:
+ text += QObject::tr("The specified file either doesn't exist or is "
+ "not a proper executable.");
+ failed = true;
+ break;
+ case JavaStatus::DoesNotStart: {
+ text += QObject::tr(
+ "The specified java binary didn't start properly.<br />");
+ auto htmlError = m_result.errorLog;
+ if (!htmlError.isEmpty()) {
+ htmlError.replace('\n', "<br />");
+ text += QString("<font color=\"red\">%1</font>").arg(htmlError);
+ }
+ failed = true;
+ break;
+ }
+ case JavaStatus::ReturnedInvalidData: {
+ text += QObject::tr(
+ "The specified java binary returned unexpected results:<br />");
+ auto htmlOut = m_result.outLog;
+ if (!htmlOut.isEmpty()) {
+ htmlOut.replace('\n', "<br />");
+ text += QString("<font color=\"red\">%1</font>").arg(htmlOut);
+ }
+ failed = true;
+ break;
+ }
+ case JavaStatus::Good:
+ text += QObject::tr("Java test succeeded!<br />Platform reported: "
+ "%1<br />Java version "
+ "reported: %2<br />")
+ .arg(m_result.realPlatform,
+ m_result.javaVersion.toString());
+ break;
+ case JavaStatus::Pending:
+ // TODO: abort here?
+ return;
+ }
+ CustomMessageBox::selectable(
+ this,
+ failed ? QObject::tr("Java test failure")
+ : QObject::tr("Java test success"),
+ text, failed ? QMessageBox::Critical : QMessageBox::Information)
+ ->show();
+}
+
+void JavaSettingsWidget::setJavaStatus(JavaSettingsWidget::JavaStatus status)
+{
+ javaStatus = status;
+ switch (javaStatus) {
+ case JavaStatus::Good:
+ m_javaStatusBtn->setIcon(goodIcon);
+ break;
+ case JavaStatus::NotSet:
+ case JavaStatus::Pending:
+ m_javaStatusBtn->setIcon(yellowIcon);
+ break;
+ default:
+ m_javaStatusBtn->setIcon(badIcon);
+ break;
+ }
+}
+
+void JavaSettingsWidget::javaPathEdited(const QString& path)
+{
+ checkJavaPathOnEdit(path);
+}
+
+void JavaSettingsWidget::checkJavaPathOnEdit(const QString& path)
+{
+ auto realPath = FS::ResolveExecutable(path);
+ QFileInfo pathInfo(realPath);
+ if (pathInfo.baseName().toLower().contains("java")) {
+ checkJavaPath(path);
+ } else {
+ if (!m_checker) {
+ setJavaStatus(JavaStatus::NotSet);
+ }
+ }
+}
+
+void JavaSettingsWidget::checkJavaPath(const QString& path)
+{
+ if (m_checker) {
+ queuedCheck = path;
+ return;
+ }
+ auto realPath = FS::ResolveExecutable(path);
+ if (realPath.isNull()) {
+ setJavaStatus(JavaStatus::DoesNotExist);
+ return;
+ }
+ setJavaStatus(JavaStatus::Pending);
+ m_checker.reset(new JavaChecker());
+ m_checker->m_path = path;
+ m_checker->m_minMem = m_minMemSpinBox->value();
+ m_checker->m_maxMem = m_maxMemSpinBox->value();
+ if (m_permGenSpinBox->isVisible()) {
+ m_checker->m_permGen = m_permGenSpinBox->value();
+ }
+ connect(m_checker.get(), &JavaChecker::checkFinished, this,
+ &JavaSettingsWidget::checkFinished);
+ m_checker->performCheck();
+}
+
+void JavaSettingsWidget::checkFinished(JavaCheckResult result)
+{
+ m_result = result;
+ switch (result.validity) {
+ case JavaCheckResult::Validity::Valid: {
+ setJavaStatus(JavaStatus::Good);
+ break;
+ }
+ case JavaCheckResult::Validity::ReturnedInvalidData: {
+ setJavaStatus(JavaStatus::ReturnedInvalidData);
+ break;
+ }
+ case JavaCheckResult::Validity::Errored: {
+ setJavaStatus(JavaStatus::DoesNotStart);
+ break;
+ }
+ }
+ m_checker.reset();
+ if (!queuedCheck.isNull()) {
+ checkJavaPath(queuedCheck);
+ queuedCheck.clear();
+ }
+}
+
+void JavaSettingsWidget::retranslate()
+{
+ m_memoryGroupBox->setTitle(tr("Memory"));
+ m_maxMemSpinBox->setToolTip(
+ tr("The maximum amount of memory Minecraft is allowed to use."));
+ m_labelMinMem->setText(tr("Minimum memory allocation:"));
+ m_labelMaxMem->setText(tr("Maximum memory allocation:"));
+ m_minMemSpinBox->setToolTip(
+ tr("The amount of memory Minecraft is started with."));
+ m_permGenSpinBox->setToolTip(
+ tr("The amount of memory available to store loaded Java classes."));
+ m_javaBrowseBtn->setText(tr("Browse"));
+}
diff --git a/meshmc/launcher/ui/widgets/JavaSettingsWidget.h b/meshmc/launcher/ui/widgets/JavaSettingsWidget.h
new file mode 100644
index 0000000000..aa4b88f698
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/JavaSettingsWidget.h
@@ -0,0 +1,116 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QWidget>
+
+#include <java/JavaChecker.h>
+#include <BaseVersion.h>
+#include <QObjectPtr.h>
+#include <QIcon>
+
+class QLineEdit;
+class VersionSelectWidget;
+class QSpinBox;
+class QPushButton;
+class QVBoxLayout;
+class QHBoxLayout;
+class QGroupBox;
+class QGridLayout;
+class QLabel;
+class QToolButton;
+
+/**
+ * This is a widget for all the Java settings dialogs and pages.
+ */
+class JavaSettingsWidget : public QWidget
+{
+ Q_OBJECT
+
+ public:
+ explicit JavaSettingsWidget(QWidget* parent);
+ virtual ~JavaSettingsWidget() {};
+
+ enum class JavaStatus {
+ NotSet,
+ Pending,
+ Good,
+ DoesNotExist,
+ DoesNotStart,
+ ReturnedInvalidData
+ } javaStatus = JavaStatus::NotSet;
+
+ enum class ValidationStatus { Bad, JavaBad, AllOK };
+
+ void refresh();
+ void initialize();
+ ValidationStatus validate();
+ void retranslate();
+
+ bool permGenEnabled() const;
+ int permGenSize() const;
+ int minHeapSize() const;
+ int maxHeapSize() const;
+ QString javaPath() const;
+
+ protected slots:
+ void memoryValueChanged(int);
+ void javaPathEdited(const QString& path);
+ void javaVersionSelected(BaseVersionPtr version);
+ void on_javaBrowseBtn_clicked();
+ void on_javaStatusBtn_clicked();
+ void checkFinished(JavaCheckResult result);
+
+ protected: /* methods */
+ void checkJavaPathOnEdit(const QString& path);
+ void checkJavaPath(const QString& path);
+ void setJavaStatus(JavaStatus status);
+ void setupUi();
+
+ private: /* data */
+ VersionSelectWidget* m_versionWidget = nullptr;
+ QVBoxLayout* m_verticalLayout = nullptr;
+
+ QLineEdit* m_javaPathTextBox = nullptr;
+ QPushButton* m_javaBrowseBtn = nullptr;
+ QToolButton* m_javaStatusBtn = nullptr;
+ QHBoxLayout* m_horizontalLayout = nullptr;
+
+ QGroupBox* m_memoryGroupBox = nullptr;
+ QGridLayout* m_gridLayout_2 = nullptr;
+ QSpinBox* m_maxMemSpinBox = nullptr;
+ QLabel* m_labelMinMem = nullptr;
+ QLabel* m_labelMaxMem = nullptr;
+ QSpinBox* m_minMemSpinBox = nullptr;
+ QLabel* m_labelPermGen = nullptr;
+ QSpinBox* m_permGenSpinBox = nullptr;
+ QIcon goodIcon;
+ QIcon yellowIcon;
+ QIcon badIcon;
+
+ int observedMinMemory = 0;
+ int observedMaxMemory = 0;
+ int observedPermGenMemory = 0;
+ QString queuedCheck;
+ uint64_t m_availableMemory = 0ull;
+ shared_qobject_ptr<JavaChecker> m_checker;
+ JavaCheckResult m_result;
+};
diff --git a/meshmc/launcher/ui/widgets/LabeledToolButton.cpp b/meshmc/launcher/ui/widgets/LabeledToolButton.cpp
new file mode 100644
index 0000000000..7829f3de46
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/LabeledToolButton.cpp
@@ -0,0 +1,136 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 <QLabel>
+#include <QVBoxLayout>
+#include <QResizeEvent>
+#include <QStyleOption>
+#include "LabeledToolButton.h"
+#include <QApplication>
+#include <QDebug>
+
+/*
+ *
+ * Tool Button with a label on it, instead of the normal text rendering
+ *
+ */
+
+LabeledToolButton::LabeledToolButton(QWidget* parent)
+ : QToolButton(parent), m_label(new QLabel(this))
+{
+ // QToolButton::setText(" ");
+ m_label->setWordWrap(true);
+ m_label->setMouseTracking(false);
+ m_label->setAlignment(Qt::AlignCenter);
+ m_label->setTextInteractionFlags(Qt::NoTextInteraction);
+ // somehow, this makes word wrap work in the QLabel. yay.
+ // m_label->setMinimumWidth(100);
+}
+
+QString LabeledToolButton::text() const
+{
+ return m_label->text();
+}
+
+void LabeledToolButton::setText(const QString& text)
+{
+ m_label->setText(text);
+}
+
+void LabeledToolButton::setIcon(QIcon icon)
+{
+ m_icon = icon;
+ resetIcon();
+}
+
+/*!
+ \reimp
+*/
+QSize LabeledToolButton::sizeHint() const
+{
+ /*
+ Q_D(const QToolButton);
+ if (d->sizeHint.isValid())
+ return d->sizeHint;
+ */
+ ensurePolished();
+
+ int w = 0, h = 0;
+ QStyleOptionToolButton opt;
+ initStyleOption(&opt);
+ QSize sz = m_label->sizeHint();
+ w = sz.width();
+ h = sz.height();
+
+ opt.rect.setSize(
+ QSize(w, h)); // PM_MenuButtonIndicator depends on the height
+ if (popupMode() == MenuButtonPopup)
+ w += style()->pixelMetric(QStyle::PM_MenuButtonIndicator, &opt, this);
+
+ QSize rawSize = style()->sizeFromContents(QStyle::CT_ToolButton, &opt,
+ QSize(w, h), this);
+ QSize sizeHint = rawSize;
+ return sizeHint;
+}
+
+void LabeledToolButton::resizeEvent(QResizeEvent* event)
+{
+ m_label->setGeometry(QRect(4, 4, width() - 8, height() - 8));
+ if (!m_icon.isNull()) {
+ resetIcon();
+ }
+ QWidget::resizeEvent(event);
+}
+
+void LabeledToolButton::resetIcon()
+{
+ auto iconSz = m_icon.actualSize(QSize(160, 80));
+ float w = iconSz.width();
+ float h = iconSz.height();
+ float ar = w / h;
+ // FIXME: hardcoded max size of 160x80
+ int newW = 80 * ar;
+ if (newW > 160)
+ newW = 160;
+ QSize newSz(newW, 80);
+ auto pixmap = m_icon.pixmap(newSz);
+ m_label->setPixmap(pixmap);
+ m_label->setMinimumHeight(80);
+ m_label->setSizePolicy(QSizePolicy::MinimumExpanding,
+ QSizePolicy::Preferred);
+}
diff --git a/meshmc/launcher/ui/widgets/LabeledToolButton.h b/meshmc/launcher/ui/widgets/LabeledToolButton.h
new file mode 100644
index 0000000000..3cfe2fa13e
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/LabeledToolButton.h
@@ -0,0 +1,64 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QPushButton>
+#include <QToolButton>
+
+class QLabel;
+
+class LabeledToolButton : public QToolButton
+{
+ Q_OBJECT
+
+ QLabel* m_label;
+ QIcon m_icon;
+
+ public:
+ LabeledToolButton(QWidget* parent = 0);
+
+ QString text() const;
+ void setText(const QString& text);
+ void setIcon(QIcon icon);
+ virtual QSize sizeHint() const;
+
+ protected:
+ void resizeEvent(QResizeEvent* event);
+ void resetIcon();
+};
diff --git a/meshmc/launcher/ui/widgets/LanguageSelectionWidget.cpp b/meshmc/launcher/ui/widgets/LanguageSelectionWidget.cpp
new file mode 100644
index 0000000000..658ce638e5
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/LanguageSelectionWidget.cpp
@@ -0,0 +1,91 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "LanguageSelectionWidget.h"
+
+#include <QVBoxLayout>
+#include <QTreeView>
+#include <QHeaderView>
+#include <QLabel>
+#include "Application.h"
+#include "translations/TranslationsModel.h"
+
+LanguageSelectionWidget::LanguageSelectionWidget(QWidget* parent)
+ : QWidget(parent)
+{
+ verticalLayout = new QVBoxLayout(this);
+ verticalLayout->setObjectName(QStringLiteral("verticalLayout"));
+ languageView = new QTreeView(this);
+ languageView->setObjectName(QStringLiteral("languageView"));
+ languageView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ languageView->setAlternatingRowColors(true);
+ languageView->setRootIsDecorated(false);
+ languageView->setItemsExpandable(false);
+ languageView->setWordWrap(true);
+ languageView->header()->setCascadingSectionResizes(true);
+ languageView->header()->setStretchLastSection(false);
+ verticalLayout->addWidget(languageView);
+ helpUsLabel = new QLabel(this);
+ helpUsLabel->setObjectName(QStringLiteral("helpUsLabel"));
+ helpUsLabel->setTextInteractionFlags(Qt::LinksAccessibleByMouse);
+ helpUsLabel->setOpenExternalLinks(true);
+ helpUsLabel->setWordWrap(true);
+ verticalLayout->addWidget(helpUsLabel);
+
+ auto translations = APPLICATION->translations();
+ auto index = translations->selectedIndex();
+ languageView->setModel(translations.get());
+ languageView->setCurrentIndex(index);
+ languageView->header()->setSectionResizeMode(QHeaderView::ResizeToContents);
+ languageView->header()->setSectionResizeMode(0, QHeaderView::Stretch);
+ connect(languageView->selectionModel(),
+ &QItemSelectionModel::currentRowChanged, this,
+ &LanguageSelectionWidget::languageRowChanged);
+ verticalLayout->setContentsMargins(0, 0, 0, 0);
+}
+
+QString LanguageSelectionWidget::getSelectedLanguageKey() const
+{
+ auto translations = APPLICATION->translations();
+ return translations->data(languageView->currentIndex(), Qt::UserRole)
+ .toString();
+}
+
+void LanguageSelectionWidget::retranslate()
+{
+ QString text = tr("Don't see your language or the quality is poor?<br/><a "
+ "href=\"%1\">Help us with translations!</a>")
+ .arg("https://github.com/Project-Tick/MeshMC/wiki/"
+ "Translating-MeshMC");
+ helpUsLabel->setText(text);
+}
+
+void LanguageSelectionWidget::languageRowChanged(const QModelIndex& current,
+ const QModelIndex& previous)
+{
+ if (current == previous) {
+ return;
+ }
+ auto translations = APPLICATION->translations();
+ QString key = translations->data(current, Qt::UserRole).toString();
+ translations->selectLanguage(key);
+ translations->updateLanguage(key);
+}
diff --git a/meshmc/launcher/ui/widgets/LanguageSelectionWidget.h b/meshmc/launcher/ui/widgets/LanguageSelectionWidget.h
new file mode 100644
index 0000000000..f279b49cbe
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/LanguageSelectionWidget.h
@@ -0,0 +1,65 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+class QVBoxLayout;
+class QTreeView;
+class QLabel;
+
+class LanguageSelectionWidget : public QWidget
+{
+ Q_OBJECT
+ public:
+ explicit LanguageSelectionWidget(QWidget* parent = 0);
+ virtual ~LanguageSelectionWidget() {};
+
+ QString getSelectedLanguageKey() const;
+ void retranslate();
+
+ protected slots:
+ void languageRowChanged(const QModelIndex& current,
+ const QModelIndex& previous);
+
+ private:
+ QVBoxLayout* verticalLayout = nullptr;
+ QTreeView* languageView = nullptr;
+ QLabel* helpUsLabel = nullptr;
+};
diff --git a/meshmc/launcher/ui/widgets/LineSeparator.cpp b/meshmc/launcher/ui/widgets/LineSeparator.cpp
new file mode 100644
index 0000000000..15a7f89a1a
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/LineSeparator.cpp
@@ -0,0 +1,59 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "LineSeparator.h"
+
+#include <QStyle>
+#include <QStyleOption>
+#include <QLayout>
+#include <QPainter>
+
+void LineSeparator::initStyleOption(QStyleOption* option) const
+{
+ option->initFrom(this);
+ // in a horizontal layout, the line is vertical (and vice versa)
+ if (m_orientation == Qt::Vertical)
+ option->state |= QStyle::State_Horizontal;
+}
+
+LineSeparator::LineSeparator(QWidget* parent, Qt::Orientation orientation)
+ : QWidget(parent), m_orientation(orientation)
+{
+ setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
+}
+
+QSize LineSeparator::sizeHint() const
+{
+ QStyleOption opt;
+ initStyleOption(&opt);
+ const int extent = style()->pixelMetric(QStyle::PM_ToolBarSeparatorExtent,
+ &opt, parentWidget());
+ return QSize(extent, extent);
+}
+
+void LineSeparator::paintEvent(QPaintEvent*)
+{
+ QPainter p(this);
+ QStyleOption opt;
+ initStyleOption(&opt);
+ style()->drawPrimitive(QStyle::PE_IndicatorToolBarSeparator, &opt, &p,
+ parentWidget());
+}
diff --git a/meshmc/launcher/ui/widgets/LineSeparator.h b/meshmc/launcher/ui/widgets/LineSeparator.h
new file mode 100644
index 0000000000..af70c8fffc
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/LineSeparator.h
@@ -0,0 +1,41 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QWidget>
+
+class QStyleOption;
+
+class LineSeparator : public QWidget
+{
+ Q_OBJECT
+
+ public:
+ /// Create a line separator. orientation is the orientation of the line.
+ explicit LineSeparator(QWidget* parent,
+ Qt::Orientation orientation = Qt::Horizontal);
+ QSize sizeHint() const;
+ void paintEvent(QPaintEvent*);
+ void initStyleOption(QStyleOption* option) const;
+
+ private:
+ Qt::Orientation m_orientation = Qt::Horizontal;
+};
diff --git a/meshmc/launcher/ui/widgets/LogView.cpp b/meshmc/launcher/ui/widgets/LogView.cpp
new file mode 100644
index 0000000000..954b48a93e
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/LogView.cpp
@@ -0,0 +1,161 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "LogView.h"
+#include <QTextBlock>
+#include <QScrollBar>
+
+LogView::LogView(QWidget* parent) : QPlainTextEdit(parent)
+{
+ setWordWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
+ m_defaultFormat = new QTextCharFormat(currentCharFormat());
+}
+
+LogView::~LogView()
+{
+ delete m_defaultFormat;
+}
+
+void LogView::setWordWrap(bool wrapping)
+{
+ if (wrapping) {
+ setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ setLineWrapMode(QPlainTextEdit::WidgetWidth);
+ } else {
+ setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
+ setLineWrapMode(QPlainTextEdit::NoWrap);
+ }
+}
+
+void LogView::setModel(QAbstractItemModel* model)
+{
+ if (m_model) {
+ disconnect(m_model, &QAbstractItemModel::modelReset, this,
+ &LogView::repopulate);
+ disconnect(m_model, &QAbstractItemModel::rowsInserted, this,
+ &LogView::rowsInserted);
+ disconnect(m_model, &QAbstractItemModel::rowsAboutToBeInserted, this,
+ &LogView::rowsAboutToBeInserted);
+ disconnect(m_model, &QAbstractItemModel::rowsRemoved, this,
+ &LogView::rowsRemoved);
+ }
+ m_model = model;
+ if (m_model) {
+ connect(m_model, &QAbstractItemModel::modelReset, this,
+ &LogView::repopulate);
+ connect(m_model, &QAbstractItemModel::rowsInserted, this,
+ &LogView::rowsInserted);
+ connect(m_model, &QAbstractItemModel::rowsAboutToBeInserted, this,
+ &LogView::rowsAboutToBeInserted);
+ connect(m_model, &QAbstractItemModel::rowsRemoved, this,
+ &LogView::rowsRemoved);
+ connect(m_model, &QAbstractItemModel::destroyed, this,
+ &LogView::modelDestroyed);
+ }
+ repopulate();
+}
+
+QAbstractItemModel* LogView::model() const
+{
+ return m_model;
+}
+
+void LogView::modelDestroyed(QObject* model)
+{
+ if (m_model == model) {
+ setModel(nullptr);
+ }
+}
+
+void LogView::repopulate()
+{
+ auto doc = document();
+ doc->clear();
+ if (!m_model) {
+ return;
+ }
+ rowsInserted(QModelIndex(), 0, m_model->rowCount() - 1);
+}
+
+void LogView::rowsAboutToBeInserted(const QModelIndex& parent, int first,
+ int last)
+{
+ Q_UNUSED(parent)
+ Q_UNUSED(first)
+ Q_UNUSED(last)
+ QScrollBar* bar = verticalScrollBar();
+ int max_bar = bar->maximum();
+ int val_bar = bar->value();
+ if (m_scroll) {
+ m_scroll = (max_bar - val_bar) <= 1;
+ } else {
+ m_scroll = val_bar == max_bar;
+ }
+}
+
+void LogView::rowsInserted(const QModelIndex& parent, int first, int last)
+{
+ for (int i = first; i <= last; i++) {
+ auto idx = m_model->index(i, 0, parent);
+ auto text = m_model->data(idx, Qt::DisplayRole).toString();
+ QTextCharFormat format(*m_defaultFormat);
+ auto font = m_model->data(idx, Qt::FontRole);
+ if (font.isValid()) {
+ format.setFont(font.value<QFont>());
+ }
+ auto fg = m_model->data(idx, Qt::ForegroundRole);
+ if (fg.isValid()) {
+ format.setForeground(fg.value<QColor>());
+ }
+ auto bg = m_model->data(idx, Qt::BackgroundRole);
+ if (bg.isValid()) {
+ format.setBackground(bg.value<QColor>());
+ }
+ auto workCursor = textCursor();
+ workCursor.movePosition(QTextCursor::End);
+ workCursor.insertText(text, format);
+ workCursor.insertBlock();
+ }
+ if (m_scroll && !m_scrolling) {
+ m_scrolling = true;
+ QMetaObject::invokeMethod(this, "scrollToBottom", Qt::QueuedConnection);
+ }
+}
+
+void LogView::rowsRemoved(const QModelIndex& parent, int first, int last)
+{
+ // TODO: some day... maybe
+ Q_UNUSED(parent)
+ Q_UNUSED(first)
+ Q_UNUSED(last)
+}
+
+void LogView::scrollToBottom()
+{
+ m_scrolling = false;
+ verticalScrollBar()->setSliderPosition(verticalScrollBar()->maximum());
+}
+
+void LogView::findNext(const QString& what, bool reverse)
+{
+ find(what, reverse ? QTextDocument::FindFlag::FindBackward
+ : QTextDocument::FindFlag(0));
+}
diff --git a/meshmc/launcher/ui/widgets/LogView.h b/meshmc/launcher/ui/widgets/LogView.h
new file mode 100644
index 0000000000..9762992f5c
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/LogView.h
@@ -0,0 +1,57 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QPlainTextEdit>
+#include <QAbstractItemView>
+
+class QAbstractItemModel;
+
+class LogView : public QPlainTextEdit
+{
+ Q_OBJECT
+ public:
+ explicit LogView(QWidget* parent = nullptr);
+ virtual ~LogView();
+
+ virtual void setModel(QAbstractItemModel* model);
+ QAbstractItemModel* model() const;
+
+ public slots:
+ void setWordWrap(bool wrapping);
+ void findNext(const QString& what, bool reverse);
+ void scrollToBottom();
+
+ protected slots:
+ void repopulate();
+ // note: this supports only appending
+ void rowsInserted(const QModelIndex& parent, int first, int last);
+ void rowsAboutToBeInserted(const QModelIndex& parent, int first, int last);
+ // note: this supports only removing from front
+ void rowsRemoved(const QModelIndex& parent, int first, int last);
+ void modelDestroyed(QObject* model);
+
+ protected:
+ QAbstractItemModel* m_model = nullptr;
+ QTextCharFormat* m_defaultFormat = nullptr;
+ bool m_scroll = false;
+ bool m_scrolling = false;
+};
diff --git a/meshmc/launcher/ui/widgets/MCModInfoFrame.cpp b/meshmc/launcher/ui/widgets/MCModInfoFrame.cpp
new file mode 100644
index 0000000000..f900f848f4
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/MCModInfoFrame.cpp
@@ -0,0 +1,173 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 <QMessageBox>
+#include <QtGui>
+
+#include "MCModInfoFrame.h"
+#include "ui_MCModInfoFrame.h"
+
+#include "ui/dialogs/CustomMessageBox.h"
+
+void MCModInfoFrame::updateWithMod(Mod& m)
+{
+ if (m.type() == m.MOD_FOLDER) {
+ clear();
+ return;
+ }
+
+ QString text = "";
+ QString name = "";
+ if (m.name().isEmpty())
+ name = m.mmc_id();
+ else
+ name = m.name();
+
+ if (m.homeurl().isEmpty())
+ text = name;
+ else
+ text = "<a href=\"" + m.homeurl() + "\">" + name + "</a>";
+ if (!m.authors().isEmpty())
+ text += " by " + m.authors().join(", ");
+
+ setModText(text);
+
+ if (m.description().isEmpty()) {
+ setModDescription(QString());
+ } else {
+ setModDescription(m.description());
+ }
+}
+
+void MCModInfoFrame::clear()
+{
+ setModText(QString());
+ setModDescription(QString());
+}
+
+MCModInfoFrame::MCModInfoFrame(QWidget* parent)
+ : QFrame(parent), ui(new Ui::MCModInfoFrame)
+{
+ ui->setupUi(this);
+ ui->label_ModDescription->setHidden(true);
+ ui->label_ModText->setHidden(true);
+ updateHiddenState();
+}
+
+MCModInfoFrame::~MCModInfoFrame()
+{
+ delete ui;
+}
+
+void MCModInfoFrame::updateHiddenState()
+{
+ if (ui->label_ModDescription->isHidden() && ui->label_ModText->isHidden()) {
+ setHidden(true);
+ } else {
+ setHidden(false);
+ }
+}
+
+void MCModInfoFrame::setModText(QString text)
+{
+ if (text.isEmpty()) {
+ ui->label_ModText->setHidden(true);
+ } else {
+ ui->label_ModText->setText(text);
+ ui->label_ModText->setHidden(false);
+ }
+ updateHiddenState();
+}
+
+void MCModInfoFrame::setModDescription(QString text)
+{
+ if (text.isEmpty()) {
+ ui->label_ModDescription->setHidden(true);
+ updateHiddenState();
+ return;
+ } else {
+ ui->label_ModDescription->setHidden(false);
+ updateHiddenState();
+ }
+ ui->label_ModDescription->setToolTip("");
+ QString intermediatetext = text.trimmed();
+ bool prev(false);
+ QChar rem('\n');
+ QString finaltext;
+ finaltext.reserve(intermediatetext.size());
+ foreach (const QChar& c, intermediatetext) {
+ if (c == rem && prev) {
+ continue;
+ }
+ prev = c == rem;
+ finaltext += c;
+ }
+ QString labeltext;
+ labeltext.reserve(300);
+ if (finaltext.length() > 290) {
+ ui->label_ModDescription->setOpenExternalLinks(false);
+ ui->label_ModDescription->setTextFormat(Qt::TextFormat::RichText);
+ desc = text;
+ // This allows injecting HTML here.
+ labeltext.append("<html><body>" + finaltext.left(287) +
+ "<a href=\"#mod_desc\">...</a></body></html>");
+ QObject::connect(ui->label_ModDescription, &QLabel::linkActivated, this,
+ &MCModInfoFrame::modDescEllipsisHandler);
+ } else {
+ ui->label_ModDescription->setTextFormat(Qt::TextFormat::PlainText);
+ labeltext.append(finaltext);
+ }
+ ui->label_ModDescription->setText(labeltext);
+}
+
+void MCModInfoFrame::modDescEllipsisHandler(const QString& link)
+{
+ if (!currentBox) {
+ currentBox = CustomMessageBox::selectable(this, QString(), desc);
+ connect(currentBox, &QMessageBox::finished, this,
+ &MCModInfoFrame::boxClosed);
+ currentBox->show();
+ } else {
+ currentBox->setText(desc);
+ }
+}
+
+void MCModInfoFrame::boxClosed(int result)
+{
+ currentBox = nullptr;
+}
diff --git a/meshmc/launcher/ui/widgets/MCModInfoFrame.h b/meshmc/launcher/ui/widgets/MCModInfoFrame.h
new file mode 100644
index 0000000000..baaab6efa5
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/MCModInfoFrame.h
@@ -0,0 +1,74 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QFrame>
+#include "minecraft/mod/Mod.h"
+
+namespace Ui
+{
+ class MCModInfoFrame;
+}
+
+class MCModInfoFrame : public QFrame
+{
+ Q_OBJECT
+
+ public:
+ explicit MCModInfoFrame(QWidget* parent = 0);
+ ~MCModInfoFrame();
+
+ void setModText(QString text);
+ void setModDescription(QString text);
+
+ void updateWithMod(Mod& m);
+ void clear();
+
+ public slots:
+ void modDescEllipsisHandler(const QString& link);
+ void boxClosed(int result);
+
+ private:
+ void updateHiddenState();
+
+ private:
+ Ui::MCModInfoFrame* ui;
+ QString desc;
+ class QMessageBox* currentBox = nullptr;
+};
diff --git a/meshmc/launcher/ui/widgets/MCModInfoFrame.ui b/meshmc/launcher/ui/widgets/MCModInfoFrame.ui
new file mode 100644
index 0000000000..5ef33379da
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/MCModInfoFrame.ui
@@ -0,0 +1,92 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MCModInfoFrame</class>
+ <widget class="QFrame" name="MCModInfoFrame">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>527</width>
+ <height>113</height>
+ </rect>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Minimum">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="maximumSize">
+ <size>
+ <width>16777215</width>
+ <height>120</height>
+ </size>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <property name="spacing">
+ <number>6</number>
+ </property>
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QLabel" name="label_ModText">
+ <property name="text">
+ <string notr="true"/>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::RichText</enum>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ <property name="openExternalLinks">
+ <bool>true</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="label_ModDescription">
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="text">
+ <string notr="true"/>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::RichText</enum>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ <property name="openExternalLinks">
+ <bool>true</bool>
+ </property>
+ <property name="textInteractionFlags">
+ <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/meshmc/launcher/ui/widgets/ModListView.cpp b/meshmc/launcher/ui/widgets/ModListView.cpp
new file mode 100644
index 0000000000..8da7896bd0
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/ModListView.cpp
@@ -0,0 +1,84 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "ModListView.h"
+#include <QHeaderView>
+#include <QMouseEvent>
+#include <QPainter>
+#include <QDrag>
+#include <QRect>
+
+ModListView::ModListView(QWidget* parent) : QTreeView(parent)
+{
+ setAllColumnsShowFocus(true);
+ setExpandsOnDoubleClick(false);
+ setRootIsDecorated(false);
+ setSortingEnabled(true);
+ setAlternatingRowColors(true);
+ setSelectionMode(QAbstractItemView::ExtendedSelection);
+ setHeaderHidden(false);
+ setSelectionBehavior(QAbstractItemView::SelectRows);
+ setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn);
+ setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
+ setDropIndicatorShown(true);
+ setDragEnabled(true);
+ setDragDropMode(QAbstractItemView::DropOnly);
+ viewport()->setAcceptDrops(true);
+}
+
+void ModListView::setModel(QAbstractItemModel* model)
+{
+ QTreeView::setModel(model);
+ auto head = header();
+ head->setStretchLastSection(false);
+ // HACK: this is true for the checkbox column of mod lists
+ auto string = model->headerData(0, head->orientation()).toString();
+ if (head->count() < 1) {
+ return;
+ }
+ if (!string.size()) {
+ head->setSectionResizeMode(0, QHeaderView::ResizeToContents);
+ head->setSectionResizeMode(1, QHeaderView::Stretch);
+ for (int i = 2; i < head->count(); i++)
+ head->setSectionResizeMode(i, QHeaderView::ResizeToContents);
+ } else {
+ head->setSectionResizeMode(0, QHeaderView::Stretch);
+ for (int i = 1; i < head->count(); i++)
+ head->setSectionResizeMode(i, QHeaderView::ResizeToContents);
+ }
+}
diff --git a/meshmc/launcher/ui/widgets/ModListView.h b/meshmc/launcher/ui/widgets/ModListView.h
new file mode 100644
index 0000000000..e8e7c95c6c
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/ModListView.h
@@ -0,0 +1,48 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+#include <QTreeView>
+
+class ModListView : public QTreeView
+{
+ Q_OBJECT
+ public:
+ explicit ModListView(QWidget* parent = 0);
+ virtual void setModel(QAbstractItemModel* model);
+};
diff --git a/meshmc/launcher/ui/widgets/PageContainer.cpp b/meshmc/launcher/ui/widgets/PageContainer.cpp
new file mode 100644
index 0000000000..5f50c7d419
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/PageContainer.cpp
@@ -0,0 +1,253 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 "PageContainer.h"
+#include "PageContainer_p.h"
+
+#include <QStackedLayout>
+#include <QPushButton>
+#include <QSortFilterProxyModel>
+#include <QUrl>
+#include <QStyledItemDelegate>
+#include <QListView>
+#include <QLineEdit>
+#include <QLabel>
+#include <QDialogButtonBox>
+#include <QGridLayout>
+
+#include "settings/SettingsObject.h"
+
+#include "ui/widgets/IconLabel.h"
+
+#include "DesktopServices.h"
+#include "Application.h"
+
+class PageEntryFilterModel : public QSortFilterProxyModel
+{
+ public:
+ explicit PageEntryFilterModel(QObject* parent = 0)
+ : QSortFilterProxyModel(parent)
+ {
+ }
+
+ protected:
+ bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const
+ {
+ const QString pattern = filterRegularExpression().pattern();
+ const auto model = static_cast<PageModel*>(sourceModel());
+ const auto page = model->pages().at(sourceRow);
+ if (!page->shouldDisplay())
+ return false;
+ // Regular contents check, then check page-filter.
+ return QSortFilterProxyModel::filterAcceptsRow(sourceRow, sourceParent);
+ }
+};
+
+PageContainer::PageContainer(BasePageProvider* pageProvider, QString defaultId,
+ QWidget* parent)
+ : QWidget(parent)
+{
+ createUI();
+ m_model = new PageModel(this);
+ m_proxyModel = new PageEntryFilterModel(this);
+ int counter = 0;
+ auto pages = pageProvider->getPages();
+ for (auto page : pages) {
+ page->stackIndex = m_pageStack->addWidget(dynamic_cast<QWidget*>(page));
+ page->listIndex = counter;
+ page->setParentContainer(this);
+ counter++;
+ }
+ m_model->setPages(pages);
+
+ m_proxyModel->setSourceModel(m_model);
+ m_proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
+
+ m_pageList->setIconSize(QSize(pageIconSize, pageIconSize));
+ m_pageList->setSelectionMode(QAbstractItemView::SingleSelection);
+ m_pageList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
+ m_pageList->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents);
+ m_pageList->setModel(m_proxyModel);
+ connect(m_pageList->selectionModel(),
+ SIGNAL(currentRowChanged(QModelIndex, QModelIndex)), this,
+ SLOT(currentChanged(QModelIndex)));
+ m_pageStack->setStackingMode(QStackedLayout::StackOne);
+ m_pageList->setFocus();
+ selectPage(defaultId);
+}
+
+bool PageContainer::selectPage(QString pageId)
+{
+ // now find what we want to have selected...
+ auto page = m_model->findPageEntryById(pageId);
+ QModelIndex index;
+ if (page) {
+ index = m_proxyModel->mapFromSource(m_model->index(page->listIndex));
+ }
+ if (!index.isValid()) {
+ index = m_proxyModel->index(0, 0);
+ }
+ if (index.isValid()) {
+ m_pageList->setCurrentIndex(index);
+ return true;
+ }
+ return false;
+}
+
+void PageContainer::refreshContainer()
+{
+ m_proxyModel->invalidate();
+ if (!m_currentPage->shouldDisplay()) {
+ auto index = m_proxyModel->index(0, 0);
+ if (index.isValid()) {
+ m_pageList->setCurrentIndex(index);
+ } else {
+ // FIXME: unhandled corner case: what to do when there's no page to
+ // select?
+ }
+ }
+}
+
+void PageContainer::createUI()
+{
+ m_pageStack = new QStackedLayout;
+ m_pageList = new PageView;
+ m_header = new QLabel();
+ m_iconHeader = new IconLabel(this, QIcon(), QSize(24, 24));
+
+ QFont headerLabelFont = m_header->font();
+ headerLabelFont.setBold(true);
+ const int pointSize = headerLabelFont.pointSize();
+ if (pointSize > 0)
+ headerLabelFont.setPointSize(pointSize + 2);
+ m_header->setFont(headerLabelFont);
+
+ QHBoxLayout* headerHLayout = new QHBoxLayout;
+ const int leftMargin =
+ APPLICATION->style()->pixelMetric(QStyle::PM_LayoutLeftMargin);
+ headerHLayout->addSpacerItem(new QSpacerItem(
+ leftMargin, 0, QSizePolicy::Fixed, QSizePolicy::Ignored));
+ headerHLayout->addWidget(m_header);
+ headerHLayout->addSpacerItem(
+ new QSpacerItem(0, 0, QSizePolicy::Expanding, QSizePolicy::Ignored));
+ headerHLayout->addWidget(m_iconHeader);
+ const int rightMargin =
+ APPLICATION->style()->pixelMetric(QStyle::PM_LayoutRightMargin);
+ headerHLayout->addSpacerItem(new QSpacerItem(
+ rightMargin, 0, QSizePolicy::Fixed, QSizePolicy::Ignored));
+ headerHLayout->setContentsMargins(0, 6, 0, 0);
+
+ m_pageStack->setContentsMargins(0, 0, 0, 0);
+ m_pageStack->addWidget(new QWidget(this));
+
+ m_layout = new QGridLayout;
+ m_layout->addLayout(headerHLayout, 0, 1, 1, 1);
+ m_layout->addWidget(m_pageList, 0, 0, 2, 1);
+ m_layout->addLayout(m_pageStack, 1, 1, 1, 1);
+ m_layout->setColumnStretch(1, 4);
+ m_layout->setContentsMargins(0, 0, 0, 6);
+ setLayout(m_layout);
+}
+
+void PageContainer::addButtons(QWidget* buttons)
+{
+ m_layout->addWidget(buttons, 2, 0, 1, 2);
+}
+
+void PageContainer::addButtons(QLayout* buttons)
+{
+ m_layout->addLayout(buttons, 2, 0, 1, 2);
+}
+
+void PageContainer::showPage(int row)
+{
+ if (m_currentPage) {
+ m_currentPage->closed();
+ }
+ if (row != -1) {
+ m_currentPage = m_model->pages().at(row);
+ } else {
+ m_currentPage = nullptr;
+ }
+ if (m_currentPage) {
+ m_pageStack->setCurrentIndex(m_currentPage->stackIndex);
+ m_header->setText(m_currentPage->displayName());
+ m_iconHeader->setIcon(m_currentPage->icon());
+ m_currentPage->opened();
+ } else {
+ m_pageStack->setCurrentIndex(0);
+ m_header->setText(QString());
+ m_iconHeader->setIcon(APPLICATION->getThemedIcon("bug"));
+ }
+}
+
+void PageContainer::help()
+{
+ if (m_currentPage) {
+ QString pageId = m_currentPage->helpPage();
+ if (pageId.isEmpty())
+ return;
+ DesktopServices::openUrl(
+ QUrl("https://github.com/Project-Tick/MeshMC/wiki/" + pageId));
+ }
+}
+
+void PageContainer::currentChanged(const QModelIndex& current)
+{
+ showPage(current.isValid() ? m_proxyModel->mapToSource(current).row() : -1);
+}
+
+bool PageContainer::prepareToClose()
+{
+ if (!saveAll()) {
+ return false;
+ }
+ if (m_currentPage) {
+ m_currentPage->closed();
+ }
+ return true;
+}
+
+bool PageContainer::saveAll()
+{
+ for (auto page : m_model->pages()) {
+ if (!page->apply())
+ return false;
+ }
+ return true;
+}
diff --git a/meshmc/launcher/ui/widgets/PageContainer.h b/meshmc/launcher/ui/widgets/PageContainer.h
new file mode 100644
index 0000000000..53eaf4f563
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/PageContainer.h
@@ -0,0 +1,112 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+#include <QModelIndex>
+
+#include "ui/pages/BasePageProvider.h"
+#include "ui/pages/BasePageContainer.h"
+
+class QLayout;
+class IconLabel;
+class QSortFilterProxyModel;
+class PageModel;
+class QLabel;
+class QListView;
+class QLineEdit;
+class QStackedLayout;
+class QGridLayout;
+
+class PageContainer : public QWidget, public BasePageContainer
+{
+ Q_OBJECT
+ public:
+ explicit PageContainer(BasePageProvider* pageProvider,
+ QString defaultId = QString(), QWidget* parent = 0);
+ virtual ~PageContainer() {}
+
+ void addButtons(QWidget* buttons);
+ void addButtons(QLayout* buttons);
+ /*
+ * Save any unsaved state and prepare to be closed.
+ * @return true if everything can be saved, false if there is something that
+ * requires attention
+ */
+ bool prepareToClose();
+ bool saveAll();
+
+ /* request close - used by individual pages */
+ bool requestClose() override
+ {
+ if (m_container) {
+ return m_container->requestClose();
+ }
+ return false;
+ }
+
+ virtual bool selectPage(QString pageId) override;
+
+ void refreshContainer() override;
+ virtual void setParentContainer(BasePageContainer* container)
+ {
+ m_container = container;
+ };
+
+ private:
+ void createUI();
+
+ public slots:
+ void help();
+
+ private slots:
+ void currentChanged(const QModelIndex& current);
+ void showPage(int row);
+
+ private:
+ BasePageContainer* m_container = nullptr;
+ BasePage* m_currentPage = 0;
+ QSortFilterProxyModel* m_proxyModel;
+ PageModel* m_model;
+ QStackedLayout* m_pageStack;
+ QListView* m_pageList;
+ QLabel* m_header;
+ IconLabel* m_iconHeader;
+ QGridLayout* m_layout;
+};
diff --git a/meshmc/launcher/ui/widgets/PageContainer_p.h b/meshmc/launcher/ui/widgets/PageContainer_p.h
new file mode 100644
index 0000000000..0d041ec57b
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/PageContainer_p.h
@@ -0,0 +1,143 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QListView>
+#include <QStyledItemDelegate>
+#include <QEvent>
+#include <QScrollBar>
+
+class BasePage;
+const int pageIconSize = 24;
+
+class PageViewDelegate : public QStyledItemDelegate
+{
+ public:
+ PageViewDelegate(QObject* parent) : QStyledItemDelegate(parent) {}
+ QSize sizeHint(const QStyleOptionViewItem& option,
+ const QModelIndex& index) const
+ {
+ QSize size = QStyledItemDelegate::sizeHint(option, index);
+ size.setHeight(qMax(size.height(), 32));
+ return size;
+ }
+};
+
+class PageModel : public QAbstractListModel
+{
+ public:
+ PageModel(QObject* parent = 0) : QAbstractListModel(parent)
+ {
+ QPixmap empty(pageIconSize, pageIconSize);
+ empty.fill(Qt::transparent);
+ m_emptyIcon = QIcon(empty);
+ }
+ virtual ~PageModel() {}
+
+ int rowCount(const QModelIndex& parent = QModelIndex()) const
+ {
+ return parent.isValid() ? 0 : m_pages.size();
+ }
+ QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const
+ {
+ switch (role) {
+ case Qt::DisplayRole:
+ return m_pages.at(index.row())->displayName();
+ case Qt::DecorationRole: {
+ QIcon icon = m_pages.at(index.row())->icon();
+ if (icon.isNull())
+ icon = m_emptyIcon;
+ // HACK: fixes icon stretching on windows. TODO: report Qt bug
+ // for this
+ return QIcon(icon.pixmap(QSize(48, 48)));
+ }
+ }
+ return QVariant();
+ }
+
+ void setPages(const QList<BasePage*>& pages)
+ {
+ beginResetModel();
+ m_pages = pages;
+ endResetModel();
+ }
+ const QList<BasePage*>& pages() const
+ {
+ return m_pages;
+ }
+
+ BasePage* findPageEntryById(QString id)
+ {
+ for (auto page : m_pages) {
+ if (page->id() == id)
+ return page;
+ }
+ return nullptr;
+ }
+
+ QList<BasePage*> m_pages;
+ QIcon m_emptyIcon;
+};
+
+class PageView : public QListView
+{
+ public:
+ PageView(QWidget* parent = 0) : QListView(parent)
+ {
+ setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Expanding);
+ setItemDelegate(new PageViewDelegate(this));
+ setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ }
+
+ virtual QSize sizeHint() const
+ {
+ int width = sizeHintForColumn(0) + frameWidth() * 2 + 5;
+ if (verticalScrollBar()->isVisible())
+ width += verticalScrollBar()->width();
+ return QSize(width, 100);
+ }
+
+ virtual bool eventFilter(QObject* obj, QEvent* event)
+ {
+ if (obj == verticalScrollBar() &&
+ (event->type() == QEvent::Show || event->type() == QEvent::Hide))
+ updateGeometry();
+ return QListView::eventFilter(obj, event);
+ }
+};
diff --git a/meshmc/launcher/ui/widgets/ProgressWidget.cpp b/meshmc/launcher/ui/widgets/ProgressWidget.cpp
new file mode 100644
index 0000000000..dedf6a005f
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/ProgressWidget.cpp
@@ -0,0 +1,95 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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:
+ *
+ */// Licensed under the Apache-2.0 license. See README.md for details.
+
+#include "ProgressWidget.h"
+#include <QProgressBar>
+#include <QLabel>
+#include <QVBoxLayout>
+#include <QEventLoop>
+
+#include "tasks/Task.h"
+
+ProgressWidget::ProgressWidget(QWidget* parent) : QWidget(parent)
+{
+ m_label = new QLabel(this);
+ m_label->setWordWrap(true);
+ m_bar = new QProgressBar(this);
+ m_bar->setMinimum(0);
+ m_bar->setMaximum(100);
+ QVBoxLayout* layout = new QVBoxLayout(this);
+ layout->addWidget(m_label);
+ layout->addWidget(m_bar);
+ layout->addStretch();
+ setLayout(layout);
+}
+
+void ProgressWidget::start(std::shared_ptr<Task> task)
+{
+ if (m_task) {
+ disconnect(m_task.get(), 0, this, 0);
+ }
+ m_task = task;
+ connect(m_task.get(), &Task::finished, this,
+ &ProgressWidget::handleTaskFinish);
+ connect(m_task.get(), &Task::status, this,
+ &ProgressWidget::handleTaskStatus);
+ connect(m_task.get(), &Task::progress, this,
+ &ProgressWidget::handleTaskProgress);
+ connect(m_task.get(), &Task::destroyed, this,
+ &ProgressWidget::taskDestroyed);
+ if (!m_task->isRunning()) {
+ QMetaObject::invokeMethod(m_task.get(), "start", Qt::QueuedConnection);
+ }
+}
+bool ProgressWidget::exec(std::shared_ptr<Task> task)
+{
+ QEventLoop loop;
+ connect(task.get(), &Task::finished, &loop, &QEventLoop::quit);
+ start(task);
+ if (task->isRunning()) {
+ loop.exec();
+ }
+ return task->wasSuccessful();
+}
+
+void ProgressWidget::handleTaskFinish()
+{
+ if (!m_task->wasSuccessful()) {
+ m_label->setText(m_task->failReason());
+ }
+}
+void ProgressWidget::handleTaskStatus(const QString& status)
+{
+ m_label->setText(status);
+}
+void ProgressWidget::handleTaskProgress(qint64 current, qint64 total)
+{
+ m_bar->setMaximum(total);
+ m_bar->setValue(current);
+}
+void ProgressWidget::taskDestroyed()
+{
+ m_task = nullptr;
+}
diff --git a/meshmc/launcher/ui/widgets/ProgressWidget.h b/meshmc/launcher/ui/widgets/ProgressWidget.h
new file mode 100644
index 0000000000..8726c00488
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/ProgressWidget.h
@@ -0,0 +1,55 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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:
+ *
+ */// Licensed under the Apache-2.0 license. See README.md for details.
+
+#pragma once
+
+#include <QWidget>
+#include <memory>
+
+class Task;
+class QProgressBar;
+class QLabel;
+
+class ProgressWidget : public QWidget
+{
+ Q_OBJECT
+ public:
+ explicit ProgressWidget(QWidget* parent = nullptr);
+
+ public slots:
+ void start(std::shared_ptr<Task> task);
+ bool exec(std::shared_ptr<Task> task);
+
+ private slots:
+ void handleTaskFinish();
+ void handleTaskStatus(const QString& status);
+ void handleTaskProgress(qint64 current, qint64 total);
+ void taskDestroyed();
+
+ private:
+ QLabel* m_label;
+ QProgressBar* m_bar;
+ std::shared_ptr<Task> m_task;
+};
diff --git a/meshmc/launcher/ui/widgets/VersionListView.cpp b/meshmc/launcher/ui/widgets/VersionListView.cpp
new file mode 100644
index 0000000000..74ac800108
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/VersionListView.cpp
@@ -0,0 +1,179 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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 <QHeaderView>
+#include <QApplication>
+#include <QMouseEvent>
+#include <QDrag>
+#include <QPainter>
+#include "VersionListView.h"
+
+VersionListView::VersionListView(QWidget* parent) : QTreeView(parent)
+{
+ m_emptyString = tr("No versions are currently available.");
+}
+
+void VersionListView::rowsInserted(const QModelIndex& parent, int start,
+ int end)
+{
+ m_itemCount += end - start + 1;
+ updateEmptyViewPort();
+ QTreeView::rowsInserted(parent, start, end);
+}
+
+void VersionListView::rowsAboutToBeRemoved(const QModelIndex& parent, int start,
+ int end)
+{
+ m_itemCount -= end - start + 1;
+ updateEmptyViewPort();
+ QTreeView::rowsInserted(parent, start, end);
+}
+
+void VersionListView::setModel(QAbstractItemModel* model)
+{
+ m_itemCount = model->rowCount();
+ updateEmptyViewPort();
+ QTreeView::setModel(model);
+}
+
+void VersionListView::reset()
+{
+ if (model()) {
+ m_itemCount = model()->rowCount();
+ } else {
+ m_itemCount = 0;
+ }
+ updateEmptyViewPort();
+ QTreeView::reset();
+}
+
+void VersionListView::setEmptyString(QString emptyString)
+{
+ m_emptyString = emptyString;
+ updateEmptyViewPort();
+}
+
+void VersionListView::setEmptyErrorString(QString emptyErrorString)
+{
+ m_emptyErrorString = emptyErrorString;
+ updateEmptyViewPort();
+}
+
+void VersionListView::setEmptyMode(VersionListView::EmptyMode mode)
+{
+ m_emptyMode = mode;
+ updateEmptyViewPort();
+}
+
+void VersionListView::updateEmptyViewPort()
+{
+#ifndef QT_NO_ACCESSIBILITY
+ setAccessibleDescription(currentEmptyString());
+#endif /* !QT_NO_ACCESSIBILITY */
+
+ if (!m_itemCount) {
+ viewport()->update();
+ }
+}
+
+void VersionListView::paintEvent(QPaintEvent* event)
+{
+ if (m_itemCount) {
+ QTreeView::paintEvent(event);
+ } else {
+ paintInfoLabel(event);
+ }
+}
+
+QString VersionListView::currentEmptyString() const
+{
+ if (m_itemCount) {
+ return QString();
+ }
+ switch (m_emptyMode) {
+ default:
+ case VersionListView::Empty:
+ return QString();
+ case VersionListView::String:
+ return m_emptyString;
+ case VersionListView::ErrorString:
+ return m_emptyErrorString;
+ }
+}
+
+void VersionListView::paintInfoLabel(QPaintEvent* event) const
+{
+ QString emptyString = currentEmptyString();
+
+ // calculate the rect for the overlay
+ QPainter painter(viewport());
+ painter.setRenderHint(QPainter::Antialiasing, true);
+ QFont font("sans", 20);
+ font.setBold(true);
+
+ QRect bounds = viewport()->geometry();
+ bounds.moveTop(0);
+ auto innerBounds = bounds;
+ innerBounds.adjust(10, 10, -10, -10);
+
+ QColor background = QApplication::palette().color(QPalette::Text);
+ QColor foreground = QApplication::palette().color(QPalette::Base);
+ foreground.setAlpha(190);
+ painter.setFont(font);
+ auto fontMetrics = painter.fontMetrics();
+ auto textRect = fontMetrics.boundingRect(
+ innerBounds, Qt::AlignHCenter | Qt::TextWordWrap, emptyString);
+ textRect.moveCenter(bounds.center());
+
+ auto wrapRect = textRect;
+ wrapRect.adjust(-10, -10, 10, 10);
+
+ // check if we are allowed to draw in our area
+ if (!event->rect().intersects(wrapRect)) {
+ return;
+ }
+
+ painter.setBrush(QBrush(background));
+ painter.setPen(foreground);
+ painter.drawRoundedRect(wrapRect, 5.0, 5.0);
+
+ painter.setPen(foreground);
+ painter.setFont(font);
+ painter.drawText(textRect, Qt::AlignHCenter | Qt::TextWordWrap,
+ emptyString);
+}
diff --git a/meshmc/launcher/ui/widgets/VersionListView.h b/meshmc/launcher/ui/widgets/VersionListView.h
new file mode 100644
index 0000000000..5c92a95dc8
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/VersionListView.h
@@ -0,0 +1,75 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+#include <QTreeView>
+
+class VersionListView : public QTreeView
+{
+ Q_OBJECT
+ public:
+ explicit VersionListView(QWidget* parent = 0);
+ virtual void paintEvent(QPaintEvent* event) override;
+ virtual void setModel(QAbstractItemModel* model) override;
+
+ enum EmptyMode { Empty, String, ErrorString };
+
+ void setEmptyString(QString emptyString);
+ void setEmptyErrorString(QString emptyErrorString);
+ void setEmptyMode(EmptyMode mode);
+
+ public slots:
+ virtual void reset() override;
+
+ protected slots:
+ virtual void rowsAboutToBeRemoved(const QModelIndex& parent, int start,
+ int end) override;
+ virtual void rowsInserted(const QModelIndex& parent, int start,
+ int end) override;
+
+ private: /* methods */
+ void paintInfoLabel(QPaintEvent* event) const;
+ void updateEmptyViewPort();
+ QString currentEmptyString() const;
+
+ private: /* variables */
+ int m_itemCount = 0;
+ QString m_emptyString;
+ QString m_emptyErrorString;
+ EmptyMode m_emptyMode = Empty;
+};
diff --git a/meshmc/launcher/ui/widgets/VersionSelectWidget.cpp b/meshmc/launcher/ui/widgets/VersionSelectWidget.cpp
new file mode 100644
index 0000000000..74ffb41ae9
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/VersionSelectWidget.cpp
@@ -0,0 +1,232 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "VersionSelectWidget.h"
+
+#include <QProgressBar>
+#include <QVBoxLayout>
+#include <QHeaderView>
+
+#include "VersionListView.h"
+#include "VersionProxyModel.h"
+
+#include "ui/dialogs/CustomMessageBox.h"
+
+VersionSelectWidget::VersionSelectWidget(QWidget* parent) : QWidget(parent)
+{
+ setObjectName(QStringLiteral("VersionSelectWidget"));
+ verticalLayout = new QVBoxLayout(this);
+ verticalLayout->setObjectName(QStringLiteral("verticalLayout"));
+ verticalLayout->setContentsMargins(0, 0, 0, 0);
+
+ m_proxyModel = new VersionProxyModel(this);
+
+ listView = new VersionListView(this);
+ listView->setObjectName(QStringLiteral("listView"));
+ listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ listView->setAlternatingRowColors(true);
+ listView->setRootIsDecorated(false);
+ listView->setItemsExpandable(false);
+ listView->setWordWrap(true);
+ listView->header()->setCascadingSectionResizes(true);
+ listView->header()->setStretchLastSection(false);
+ listView->setModel(m_proxyModel);
+ verticalLayout->addWidget(listView);
+
+ sneakyProgressBar = new QProgressBar(this);
+ sneakyProgressBar->setObjectName(QStringLiteral("sneakyProgressBar"));
+ sneakyProgressBar->setFormat(QStringLiteral("%p%"));
+ verticalLayout->addWidget(sneakyProgressBar);
+ sneakyProgressBar->setHidden(true);
+ connect(listView->selectionModel(), &QItemSelectionModel::currentRowChanged,
+ this, &VersionSelectWidget::currentRowChanged);
+
+ QMetaObject::connectSlotsByName(this);
+}
+
+void VersionSelectWidget::setCurrentVersion(const QString& version)
+{
+ m_currentVersion = version;
+ m_proxyModel->setCurrentVersion(version);
+}
+
+void VersionSelectWidget::setEmptyString(QString emptyString)
+{
+ listView->setEmptyString(emptyString);
+}
+
+void VersionSelectWidget::setEmptyErrorString(QString emptyErrorString)
+{
+ listView->setEmptyErrorString(emptyErrorString);
+}
+
+VersionSelectWidget::~VersionSelectWidget() {}
+
+void VersionSelectWidget::setResizeOn(int column)
+{
+ listView->header()->setSectionResizeMode(resizeOnColumn,
+ QHeaderView::ResizeToContents);
+ resizeOnColumn = column;
+ listView->header()->setSectionResizeMode(resizeOnColumn,
+ QHeaderView::Stretch);
+}
+
+void VersionSelectWidget::initialize(BaseVersionList* vlist)
+{
+ m_vlist = vlist;
+ m_proxyModel->setSourceModel(vlist);
+ listView->header()->setSectionResizeMode(QHeaderView::ResizeToContents);
+ listView->header()->setSectionResizeMode(resizeOnColumn,
+ QHeaderView::Stretch);
+
+ if (!m_vlist->isLoaded()) {
+ loadList();
+ } else {
+ if (m_proxyModel->rowCount() == 0) {
+ listView->setEmptyMode(VersionListView::String);
+ }
+ preselect();
+ }
+}
+
+void VersionSelectWidget::closeEvent(QCloseEvent* event)
+{
+ QWidget::closeEvent(event);
+}
+
+void VersionSelectWidget::loadList()
+{
+ auto newTask = m_vlist->getLoadTask();
+ if (!newTask) {
+ return;
+ }
+ loadTask = newTask.get();
+ connect(loadTask, &Task::succeeded, this,
+ &VersionSelectWidget::onTaskSucceeded);
+ connect(loadTask, &Task::failed, this, &VersionSelectWidget::onTaskFailed);
+ connect(loadTask, &Task::progress, this,
+ &VersionSelectWidget::changeProgress);
+ if (!loadTask->isRunning()) {
+ loadTask->start();
+ }
+ sneakyProgressBar->setHidden(false);
+}
+
+void VersionSelectWidget::onTaskSucceeded()
+{
+ if (m_proxyModel->rowCount() == 0) {
+ listView->setEmptyMode(VersionListView::String);
+ }
+ sneakyProgressBar->setHidden(true);
+ preselect();
+ loadTask = nullptr;
+}
+
+void VersionSelectWidget::onTaskFailed(const QString& reason)
+{
+ CustomMessageBox::selectable(this, tr("Error"),
+ tr("List update failed:\n%1").arg(reason),
+ QMessageBox::Warning)
+ ->show();
+ onTaskSucceeded();
+}
+
+void VersionSelectWidget::changeProgress(qint64 current, qint64 total)
+{
+ sneakyProgressBar->setMaximum(total);
+ sneakyProgressBar->setValue(current);
+}
+
+void VersionSelectWidget::currentRowChanged(const QModelIndex& current,
+ const QModelIndex&)
+{
+ auto variant =
+ m_proxyModel->data(current, BaseVersionList::VersionPointerRole);
+ emit selectedVersionChanged(variant.value<BaseVersionPtr>());
+}
+
+void VersionSelectWidget::preselect()
+{
+ if (preselectedAlready)
+ return;
+ selectCurrent();
+ if (preselectedAlready)
+ return;
+ selectRecommended();
+}
+
+void VersionSelectWidget::selectCurrent()
+{
+ if (m_currentVersion.isEmpty()) {
+ return;
+ }
+ auto idx = m_proxyModel->getVersion(m_currentVersion);
+ if (idx.isValid()) {
+ preselectedAlready = true;
+ listView->selectionModel()->setCurrentIndex(
+ idx,
+ QItemSelectionModel::SelectCurrent | QItemSelectionModel::Rows);
+ listView->scrollTo(idx, QAbstractItemView::PositionAtCenter);
+ }
+}
+
+void VersionSelectWidget::selectRecommended()
+{
+ auto idx = m_proxyModel->getRecommended();
+ if (idx.isValid()) {
+ preselectedAlready = true;
+ listView->selectionModel()->setCurrentIndex(
+ idx,
+ QItemSelectionModel::SelectCurrent | QItemSelectionModel::Rows);
+ listView->scrollTo(idx, QAbstractItemView::PositionAtCenter);
+ }
+}
+
+bool VersionSelectWidget::hasVersions() const
+{
+ return m_proxyModel->rowCount(QModelIndex()) != 0;
+}
+
+BaseVersionPtr VersionSelectWidget::selectedVersion() const
+{
+ auto currentIndex = listView->selectionModel()->currentIndex();
+ auto variant =
+ m_proxyModel->data(currentIndex, BaseVersionList::VersionPointerRole);
+ return variant.value<BaseVersionPtr>();
+}
+
+void VersionSelectWidget::setExactFilter(BaseVersionList::ModelRoles role,
+ QString filter)
+{
+ m_proxyModel->setFilter(role, new ExactFilter(filter));
+}
+
+void VersionSelectWidget::setFuzzyFilter(BaseVersionList::ModelRoles role,
+ QString filter)
+{
+ m_proxyModel->setFilter(role, new ContainsFilter(filter));
+}
+
+void VersionSelectWidget::setFilter(BaseVersionList::ModelRoles role,
+ Filter* filter)
+{
+ m_proxyModel->setFilter(role, filter);
+}
diff --git a/meshmc/launcher/ui/widgets/VersionSelectWidget.h b/meshmc/launcher/ui/widgets/VersionSelectWidget.h
new file mode 100644
index 0000000000..3adf128d44
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/VersionSelectWidget.h
@@ -0,0 +1,104 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QWidget>
+#include <QSortFilterProxyModel>
+#include "BaseVersionList.h"
+
+class VersionProxyModel;
+class VersionListView;
+class QVBoxLayout;
+class QProgressBar;
+class Filter;
+
+class VersionSelectWidget : public QWidget
+{
+ Q_OBJECT
+ public:
+ explicit VersionSelectWidget(QWidget* parent = 0);
+ ~VersionSelectWidget();
+
+ //! loads the list if needed.
+ void initialize(BaseVersionList* vlist);
+
+ //! Starts a task that loads the list.
+ void loadList();
+
+ bool hasVersions() const;
+ BaseVersionPtr selectedVersion() const;
+ void selectRecommended();
+ void selectCurrent();
+
+ void setCurrentVersion(const QString& version);
+ void setFuzzyFilter(BaseVersionList::ModelRoles role, QString filter);
+ void setExactFilter(BaseVersionList::ModelRoles role, QString filter);
+ void setFilter(BaseVersionList::ModelRoles role, Filter* filter);
+ void setEmptyString(QString emptyString);
+ void setEmptyErrorString(QString emptyErrorString);
+ void setResizeOn(int column);
+
+ signals:
+ void selectedVersionChanged(BaseVersionPtr version);
+
+ protected:
+ virtual void closeEvent(QCloseEvent*);
+
+ private slots:
+ void onTaskSucceeded();
+ void onTaskFailed(const QString& reason);
+ void changeProgress(qint64 current, qint64 total);
+ void currentRowChanged(const QModelIndex& current, const QModelIndex&);
+
+ private:
+ void preselect();
+
+ private:
+ QString m_currentVersion;
+ BaseVersionList* m_vlist = nullptr;
+ VersionProxyModel* m_proxyModel = nullptr;
+ int resizeOnColumn = 0;
+ Task* loadTask;
+ bool preselectedAlready = false;
+
+ private:
+ QVBoxLayout* verticalLayout = nullptr;
+ VersionListView* listView = nullptr;
+ QProgressBar* sneakyProgressBar = nullptr;
+};
diff --git a/meshmc/launcher/ui/widgets/WideBar.cpp b/meshmc/launcher/ui/widgets/WideBar.cpp
new file mode 100644
index 0000000000..dfcb9737c8
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/WideBar.cpp
@@ -0,0 +1,135 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "WideBar.h"
+#include <QToolButton>
+#include <QMenu>
+
+class ActionButton : public QToolButton
+{
+ Q_OBJECT
+ public:
+ ActionButton(QAction* action, QWidget* parent = 0)
+ : QToolButton(parent), m_action(action)
+ {
+ setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+ connect(action, &QAction::changed, this, &ActionButton::actionChanged);
+ connect(this, &ActionButton::clicked, action, &QAction::trigger);
+ actionChanged();
+ };
+ private slots:
+ void actionChanged()
+ {
+ setEnabled(m_action->isEnabled());
+ setChecked(m_action->isChecked());
+ setCheckable(m_action->isCheckable());
+ setText(m_action->text());
+ setIcon(m_action->icon());
+ setToolTip(m_action->toolTip());
+ setHidden(!m_action->isVisible());
+ setFocusPolicy(Qt::NoFocus);
+ }
+
+ private:
+ QAction* m_action;
+};
+
+WideBar::WideBar(const QString& title, QWidget* parent)
+ : QToolBar(title, parent)
+{
+ setFloatable(false);
+ setMovable(false);
+}
+
+WideBar::WideBar(QWidget* parent) : QToolBar(parent)
+{
+ setFloatable(false);
+ setMovable(false);
+}
+
+struct WideBar::BarEntry {
+ enum Type { None, Action, Separator, Spacer } type = None;
+ QAction* qAction = nullptr;
+ QAction* wideAction = nullptr;
+};
+
+WideBar::~WideBar()
+{
+ for (auto* iter : m_entries) {
+ delete iter;
+ }
+}
+
+void WideBar::addAction(QAction* action)
+{
+ auto entry = new BarEntry();
+ entry->qAction = addWidget(new ActionButton(action, this));
+ entry->wideAction = action;
+ entry->type = BarEntry::Action;
+ m_entries.push_back(entry);
+}
+
+void WideBar::addSeparator()
+{
+ auto entry = new BarEntry();
+ entry->qAction = QToolBar::addSeparator();
+ entry->type = BarEntry::Separator;
+ m_entries.push_back(entry);
+}
+
+void WideBar::insertSpacer(QAction* action)
+{
+ auto iter = std::find_if(
+ m_entries.begin(), m_entries.end(),
+ [action](BarEntry* entry) { return entry->wideAction == action; });
+ if (iter == m_entries.end()) {
+ return;
+ }
+ QWidget* spacer = new QWidget();
+ spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+
+ auto entry = new BarEntry();
+ entry->qAction = insertWidget((*iter)->qAction, spacer);
+ entry->type = BarEntry::Spacer;
+ m_entries.insert(iter, entry);
+}
+
+QMenu* WideBar::createContextMenu(QWidget* parent, const QString& title)
+{
+ QMenu* contextMenu = new QMenu(title, parent);
+ for (auto& item : m_entries) {
+ switch (item->type) {
+ default:
+ case BarEntry::None:
+ break;
+ case BarEntry::Separator:
+ case BarEntry::Spacer:
+ contextMenu->addSeparator();
+ break;
+ case BarEntry::Action:
+ contextMenu->addAction(item->wideAction);
+ break;
+ }
+ }
+ return contextMenu;
+}
+
+#include "WideBar.moc"
diff --git a/meshmc/launcher/ui/widgets/WideBar.h b/meshmc/launcher/ui/widgets/WideBar.h
new file mode 100644
index 0000000000..ff2c7a6c01
--- /dev/null
+++ b/meshmc/launcher/ui/widgets/WideBar.h
@@ -0,0 +1,48 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * 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, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QToolBar>
+#include <QAction>
+#include <QMap>
+
+class QMenu;
+
+class WideBar : public QToolBar
+{
+ Q_OBJECT
+
+ public:
+ explicit WideBar(const QString& title, QWidget* parent = nullptr);
+ explicit WideBar(QWidget* parent = nullptr);
+ virtual ~WideBar();
+
+ void addAction(QAction* action);
+ void addSeparator();
+ void insertSpacer(QAction* action);
+ QMenu* createContextMenu(QWidget* parent = nullptr,
+ const QString& title = QString());
+
+ private:
+ struct BarEntry;
+ QList<BarEntry*> m_entries;
+};