diff options
Diffstat (limited to 'meshmc/launcher/ui')
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><html><head/><body><p>A custom launcher that makes managing Minecraft easier by allowing you to have multiple instances of Minecraft at once.</p></body></html></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>&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>&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>&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>&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>&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>&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"><html><head/><body><p><a href="https://www.ej-technologies.com/products/jprofiler/overview.html">https://www.ej-technologies.com/products/jprofiler/overview.html</a></p></body></html></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"><html><head/><body><p><a href="https://visualvm.github.io/">https://visualvm.github.io/</a></p></body></html></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"><html><head/><body><p><a href="https://www.mcedit.net/">https://www.mcedit.net/</a></p></body></html></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&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&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>&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>&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 &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 &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><html><head/> +<body> +<p>MeshMC sends anonymous usage statistics on every start of the application.</p><p>The following data is collected:</p> +<ul> +<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> +</body></html></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&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&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 &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>&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><html><head/><body><p><a href="https://paste.ee">paste.ee</a> is used by MeshMC for log uploads. If you have a <a href="https://paste.ee">paste.ee</a> account, you can add your API key here and have your uploaded logs paired with your account.</p></body></html></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>&Default</string> + </property> + <attribute name="buttonGroup"> + <string notr="true">proxyGroup</string> + </attribute> + </widget> + </item> + <item> + <widget class="QRadioButton" name="proxyNoneBtn"> + <property name="text"> + <string>&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&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&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&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&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&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&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><html><body><h1>Upgrade is required</h1><p>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.</p><p>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.</p><p>Please report any issues on our <a href="https://github.com/Project-Tick/MeshMC/issues">github issues page</a>.</p><p>There is also a <a href="https://discord.gg/GtPmv93">discord channel for testing here</a>.</p></body></html></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>&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>&Add</string> + </property> + <property name="toolTip"> + <string>Add mods</string> + </property> + </action> + <action name="actionRemove"> + <property name="text"> + <string>&Remove</string> + </property> + <property name="toolTip"> + <string>Remove selected mods</string> + </property> + </action> + <action name="actionEnable"> + <property name="text"> + <string>&Enable</string> + </property> + <property name="toolTip"> + <string>Enable selected mods</string> + </property> + </action> + <action name="actionDisable"> + <property name="text"> + <string>&Disable</string> + </property> + <property name="toolTip"> + <string>Disable selected mods</string> + </property> + </action> + <action name="actionView_configs"> + <property name="text"> + <string>View &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 &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>&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>&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&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&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><html><head/><body><p>Pre-launch command runs before the instance launches and post-exit command runs after it exits.</p><p>Both will be run in MeshMC's working folder with extra environment variables:</p><ul><li>$INST_NAME - Name of the instance</li><li>$INST_ID - ID of the instance (its folder name)</li><li>$INST_DIR - absolute path of the instance</li><li>$INST_MC_DIR - absolute path of minecraft</li><li>$INST_JAVA - java binary used for launch</li><li>$INST_JAVA_ARGS - command-line parameters used for launch</li></ul><p>Wrapper command allows launching using an extra wrapper program (like 'optirun' on Linux)</p></body></html></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>&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>&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; +}; |
