diff options
| author | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-04-02 18:45:07 +0300 |
|---|---|---|
| committer | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-04-02 18:45:07 +0300 |
| commit | 31b9a8949ed0a288143e23bf739f2eb64fdc63be (patch) | |
| tree | 8a984fa143c38fccad461a77792d6864f3e82cd3 /meshmc/launcher/ui/pages/instance | |
| parent | 934382c8a1ce738589dee9ee0f14e1cec812770e (diff) | |
| parent | fad6a1066616b69d7f5fef01178efdf014c59537 (diff) | |
| download | Project-Tick-31b9a8949ed0a288143e23bf739f2eb64fdc63be.tar.gz Project-Tick-31b9a8949ed0a288143e23bf739f2eb64fdc63be.zip | |
Add 'meshmc/' from commit 'fad6a1066616b69d7f5fef01178efdf014c59537'
git-subtree-dir: meshmc
git-subtree-mainline: 934382c8a1ce738589dee9ee0f14e1cec812770e
git-subtree-split: fad6a1066616b69d7f5fef01178efdf014c59537
Diffstat (limited to 'meshmc/launcher/ui/pages/instance')
36 files changed, 7295 insertions, 0 deletions
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> |
