1076 lines
36 KiB
C++
1076 lines
36 KiB
C++
#include "MetaCoreFoundation/MetaCoreProject.h"
|
|
|
|
#include <QApplication>
|
|
#include <QCheckBox>
|
|
#include <QComboBox>
|
|
#include <QCoreApplication>
|
|
#include <QDate>
|
|
#include <QDateTime>
|
|
#include <QDesktopServices>
|
|
#include <QDialog>
|
|
#include <QDir>
|
|
#include <QFileDialog>
|
|
#include <QFrame>
|
|
#include <QHBoxLayout>
|
|
#include <QLabel>
|
|
#include <QLineEdit>
|
|
#include <QMainWindow>
|
|
#include <QMessageBox>
|
|
#include <QMouseEvent>
|
|
#include <QProcess>
|
|
#include <QProcessEnvironment>
|
|
#include <QPushButton>
|
|
#include <QScrollArea>
|
|
#include <QSettings>
|
|
#include <QSizePolicy>
|
|
#include <QStackedWidget>
|
|
#include <QStandardPaths>
|
|
#include <QStringList>
|
|
#include <QStyle>
|
|
#include <QUrl>
|
|
#include <QVBoxLayout>
|
|
#include <QWidget>
|
|
|
|
#include <algorithm>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <functional>
|
|
#include <optional>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
struct LauncherProject {
|
|
QString Name{};
|
|
std::filesystem::path RootPath{};
|
|
QString UpdatedDate{};
|
|
};
|
|
|
|
constexpr const char* OrganizationName = "MetaCore";
|
|
constexpr const char* ApplicationName = "MetaCore Hub";
|
|
constexpr const char* NewProjectSettingsGroup = "NewProjectDialog";
|
|
constexpr const char* LastProjectPathKey = "lastProjectPath";
|
|
|
|
std::filesystem::path ToPath(const QString& text) {
|
|
return std::filesystem::path(text.toStdWString());
|
|
}
|
|
|
|
QString ToQString(const std::filesystem::path& path) {
|
|
return QString::fromStdWString(path.wstring());
|
|
}
|
|
|
|
std::filesystem::path GetExecutableDirectory() {
|
|
return ToPath(QCoreApplication::applicationDirPath());
|
|
}
|
|
|
|
std::filesystem::path GetDatabasePath() {
|
|
QString dataLocation = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
|
|
if (dataLocation.isEmpty()) {
|
|
dataLocation = QDir::homePath() + "/AppData/Local/MetaCore/Hub";
|
|
}
|
|
return ToPath(dataLocation) / "projects.tsv";
|
|
}
|
|
|
|
std::filesystem::path GetDefaultProjectLocation() {
|
|
QString documents = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
|
|
if (documents.isEmpty()) {
|
|
documents = QDir::homePath();
|
|
}
|
|
return ToPath(documents) / "MetaCore Projects";
|
|
}
|
|
|
|
bool IsProjectRoot(const std::filesystem::path& path) {
|
|
return std::filesystem::is_regular_file(MetaCore::MetaCoreGetProjectFilePath(path));
|
|
}
|
|
|
|
bool WriteTextFile(const std::filesystem::path& path, const std::string& text, QString& errorMessage) {
|
|
std::error_code error;
|
|
if (!path.parent_path().empty()) {
|
|
std::filesystem::create_directories(path.parent_path(), error);
|
|
if (error) {
|
|
errorMessage = QString("Unable to create folder for %1: %2")
|
|
.arg(ToQString(path))
|
|
.arg(QString::fromStdString(error.message()));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
std::ofstream output(path, std::ios::binary | std::ios::trunc);
|
|
if (!output.is_open()) {
|
|
errorMessage = QString("Unable to write %1.").arg(ToQString(path));
|
|
return false;
|
|
}
|
|
|
|
output << text;
|
|
return true;
|
|
}
|
|
|
|
QPushButton* CreateButton(const QString& text, const QString& objectName = {}) {
|
|
auto* button = new QPushButton(text);
|
|
button->setCursor(Qt::PointingHandCursor);
|
|
if (!objectName.isEmpty()) {
|
|
button->setObjectName(objectName);
|
|
}
|
|
return button;
|
|
}
|
|
|
|
QLabel* CreateMutedLabel(const QString& text) {
|
|
auto* label = new QLabel(text);
|
|
label->setObjectName("mutedText");
|
|
return label;
|
|
}
|
|
|
|
void ClearLayout(QLayout* layout) {
|
|
if (layout == nullptr) {
|
|
return;
|
|
}
|
|
|
|
while (QLayoutItem* item = layout->takeAt(0)) {
|
|
if (QWidget* widget = item->widget()) {
|
|
widget->deleteLater();
|
|
}
|
|
if (QLayout* childLayout = item->layout()) {
|
|
ClearLayout(childLayout);
|
|
}
|
|
delete item;
|
|
}
|
|
}
|
|
|
|
bool CreateProjectOnDisk(const std::filesystem::path& rootPath, const QString& name, QString& errorMessage) {
|
|
if (rootPath.empty()) {
|
|
errorMessage = "Project location is required.";
|
|
return false;
|
|
}
|
|
|
|
std::error_code error;
|
|
if (std::filesystem::exists(rootPath, error) && !std::filesystem::is_empty(rootPath, error)) {
|
|
errorMessage = "Project folder already exists and is not empty.";
|
|
return false;
|
|
}
|
|
|
|
const std::vector<std::filesystem::path> directories = {
|
|
rootPath / "ProjectSettings",
|
|
rootPath / "Logs",
|
|
rootPath / "Library",
|
|
rootPath / "Assets",
|
|
rootPath / "Assets" / "Models",
|
|
rootPath / "Assets" / "Materials",
|
|
rootPath / "Assets" / "Textures",
|
|
rootPath / "Assets" / "Prefabs",
|
|
rootPath / "Scenes",
|
|
rootPath / "Runtime",
|
|
rootPath / "Ui",
|
|
rootPath / "Build",
|
|
rootPath / ".vscode"
|
|
};
|
|
|
|
for (const std::filesystem::path& directory : directories) {
|
|
std::filesystem::create_directories(directory, error);
|
|
if (error) {
|
|
errorMessage = QString("Unable to create project folder %1: %2")
|
|
.arg(ToQString(directory))
|
|
.arg(QString::fromStdString(error.message()));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
MetaCore::MetaCoreProjectFileDocument document;
|
|
document.Name = name.toStdString();
|
|
document.Version = "0.1.0";
|
|
document.RuntimeDirectory = "Runtime";
|
|
document.UiDirectory = "Ui";
|
|
document.BuildDirectory = "Build";
|
|
document.StartupScenePath = std::filesystem::path("Scenes") / "Main.mcscene.json";
|
|
|
|
if (!MetaCore::MetaCoreWriteProjectFile(MetaCore::MetaCoreGetProjectFilePath(rootPath), document)) {
|
|
errorMessage = "Unable to write MetaCore.project.json.";
|
|
return false;
|
|
}
|
|
|
|
if (!WriteTextFile(
|
|
rootPath / "Assets" / "README.md",
|
|
"# Project Assets\n\nThis folder contains all assets for the MetaCore project.\n",
|
|
errorMessage
|
|
)) {
|
|
return false;
|
|
}
|
|
|
|
const std::string createdAt = QDateTime::currentDateTime().toString(Qt::ISODate).toStdString();
|
|
std::ostringstream ini;
|
|
ini << "[Project]\n";
|
|
ini << "name = " << name.toStdString() << "\n";
|
|
ini << "path = " << ToQString(rootPath).toStdString() << "\n";
|
|
ini << "created_at = " << createdAt << "\n";
|
|
ini << "changed_at = " << createdAt << "\n";
|
|
if (!WriteTextFile(rootPath / (name.toStdString() + ".ini"), ini.str(), errorMessage)) {
|
|
return false;
|
|
}
|
|
|
|
if (!WriteTextFile(
|
|
rootPath / ".vscode" / "settings.json",
|
|
"{\n"
|
|
" \"editor.formatOnSave\": true,\n"
|
|
" \"files.exclude\": {\n"
|
|
" \"Library\": true,\n"
|
|
" \"Logs\": true,\n"
|
|
" \"Build\": true\n"
|
|
" }\n"
|
|
"}\n",
|
|
errorMessage
|
|
)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
class ProjectCard final : public QFrame {
|
|
public:
|
|
explicit ProjectCard(LauncherProject project, QWidget* parent = nullptr)
|
|
: QFrame(parent),
|
|
Project_(std::move(project)) {
|
|
setObjectName("projectCard");
|
|
setCursor(Qt::PointingHandCursor);
|
|
setMinimumHeight(72);
|
|
setMaximumHeight(72);
|
|
|
|
auto* rootLayout = new QHBoxLayout(this);
|
|
rootLayout->setContentsMargins(16, 10, 12, 10);
|
|
rootLayout->setSpacing(14);
|
|
|
|
Avatar_ = new QLabel(Project_.Name.left(1).toUpper());
|
|
Avatar_->setObjectName("projectAvatar");
|
|
Avatar_->setAlignment(Qt::AlignCenter);
|
|
Avatar_->setFixedSize(44, 44);
|
|
rootLayout->addWidget(Avatar_);
|
|
|
|
auto* textLayout = new QVBoxLayout();
|
|
textLayout->setContentsMargins(0, 0, 0, 0);
|
|
textLayout->setSpacing(2);
|
|
auto* nameLabel = new QLabel(Project_.Name);
|
|
nameLabel->setObjectName("projectName");
|
|
auto* pathLabel = CreateMutedLabel(ToQString(Project_.RootPath));
|
|
pathLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
|
pathLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
|
textLayout->addWidget(nameLabel);
|
|
textLayout->addWidget(pathLabel);
|
|
rootLayout->addLayout(textLayout, 1);
|
|
|
|
auto* dateLabel = CreateMutedLabel(Project_.UpdatedDate);
|
|
dateLabel->setMinimumWidth(88);
|
|
rootLayout->addWidget(dateLabel);
|
|
|
|
auto* openButton = CreateButton("Open");
|
|
openButton->setObjectName("smallButton");
|
|
openButton->setFixedWidth(64);
|
|
connect(openButton, &QPushButton::clicked, this, [this]() {
|
|
QDesktopServices::openUrl(QUrl::fromLocalFile(ToQString(Project_.RootPath)));
|
|
});
|
|
rootLayout->addWidget(openButton);
|
|
}
|
|
|
|
void SetSelected(bool selected) {
|
|
setProperty("selected", selected);
|
|
style()->unpolish(this);
|
|
style()->polish(this);
|
|
}
|
|
|
|
const LauncherProject& Project() const {
|
|
return Project_;
|
|
}
|
|
|
|
protected:
|
|
void mousePressEvent(QMouseEvent* event) override {
|
|
emitSelected();
|
|
QFrame::mousePressEvent(event);
|
|
}
|
|
|
|
private:
|
|
void emitSelected() {
|
|
if (OnSelected) {
|
|
OnSelected();
|
|
}
|
|
}
|
|
|
|
public:
|
|
std::function<void()> OnSelected{};
|
|
|
|
private:
|
|
LauncherProject Project_{};
|
|
QLabel* Avatar_ = nullptr;
|
|
};
|
|
|
|
class NewProjectDialog final : public QDialog {
|
|
public:
|
|
explicit NewProjectDialog(QWidget* parent = nullptr)
|
|
: QDialog(parent) {
|
|
setWindowTitle("Create New Project");
|
|
setMinimumWidth(520);
|
|
setModal(true);
|
|
|
|
auto* rootLayout = new QVBoxLayout(this);
|
|
rootLayout->setContentsMargins(22, 20, 22, 18);
|
|
rootLayout->setSpacing(10);
|
|
|
|
rootLayout->addWidget(new QLabel("Project Name:"));
|
|
NameEdit_ = new QLineEdit();
|
|
NameEdit_->setPlaceholderText("Enter a name for your project");
|
|
rootLayout->addWidget(NameEdit_);
|
|
|
|
rootLayout->addWidget(new QLabel("Project Location:"));
|
|
auto* locationLayout = new QHBoxLayout();
|
|
LocationEdit_ = new QLineEdit();
|
|
LocationEdit_->setReadOnly(true);
|
|
LocationEdit_->setPlaceholderText("No path selected");
|
|
auto* browseButton = CreateButton("Browse...");
|
|
browseButton->setFixedWidth(90);
|
|
locationLayout->addWidget(LocationEdit_, 1);
|
|
locationLayout->addWidget(browseButton);
|
|
rootLayout->addLayout(locationLayout);
|
|
|
|
QSettings settings(OrganizationName, ApplicationName);
|
|
const QString lastPath = settings.value(
|
|
QString("%1/%2").arg(NewProjectSettingsGroup, LastProjectPathKey),
|
|
QString()
|
|
).toString();
|
|
if (!lastPath.isEmpty()) {
|
|
LocationEdit_->setText(lastPath);
|
|
}
|
|
|
|
connect(browseButton, &QPushButton::clicked, this, [this]() {
|
|
const QString currentPath = !LocationEdit_->text().isEmpty() ? LocationEdit_->text() : ToQString(GetDefaultProjectLocation());
|
|
const QString selectedPath = QFileDialog::getExistingDirectory(
|
|
this,
|
|
"Project Location",
|
|
currentPath
|
|
);
|
|
if (!selectedPath.isEmpty()) {
|
|
LocationEdit_->setText(selectedPath);
|
|
QSettings settings(OrganizationName, ApplicationName);
|
|
settings.setValue(QString("%1/%2").arg(NewProjectSettingsGroup, LastProjectPathKey), selectedPath);
|
|
UpdateCreateButtonState();
|
|
}
|
|
});
|
|
|
|
rootLayout->addWidget(new QLabel("Engine Version:"));
|
|
VersionCombo_ = new QComboBox();
|
|
VersionCombo_->setFixedHeight(32);
|
|
VersionCombo_->addItem("dev (current environment)", "dev-current");
|
|
rootLayout->addWidget(VersionCombo_);
|
|
|
|
ErrorLabel_ = new QLabel();
|
|
ErrorLabel_->setObjectName("errorText");
|
|
ErrorLabel_->setWordWrap(true);
|
|
ErrorLabel_->hide();
|
|
rootLayout->addWidget(ErrorLabel_);
|
|
|
|
auto* buttonsLayout = new QHBoxLayout();
|
|
buttonsLayout->addStretch(1);
|
|
auto* cancelButton = CreateButton("Cancel");
|
|
CreateButton_ = CreateButton("Create", "primaryButton");
|
|
cancelButton->setFixedWidth(90);
|
|
CreateButton_->setFixedWidth(90);
|
|
buttonsLayout->addWidget(cancelButton);
|
|
buttonsLayout->addWidget(CreateButton_);
|
|
rootLayout->addLayout(buttonsLayout);
|
|
|
|
connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject);
|
|
connect(CreateButton_, &QPushButton::clicked, this, [this]() {
|
|
accept();
|
|
});
|
|
connect(NameEdit_, &QLineEdit::textChanged, this, [this]() {
|
|
UpdateCreateButtonState();
|
|
});
|
|
connect(VersionCombo_, &QComboBox::currentTextChanged, this, [this]() {
|
|
UpdateCreateButtonState();
|
|
});
|
|
UpdateCreateButtonState();
|
|
}
|
|
|
|
QString ProjectName() const {
|
|
return NameEdit_->text().trimmed();
|
|
}
|
|
|
|
std::filesystem::path ProjectRootPath() const {
|
|
return ToPath(LocationEdit_->text()) / ToPath(ProjectName());
|
|
}
|
|
|
|
void accept() override {
|
|
UpdateCreateButtonState();
|
|
if (!CreateButton_->isEnabled()) {
|
|
ShowError("Project name, location, and engine version are required.");
|
|
return;
|
|
}
|
|
if (ProjectName().contains('/') || ProjectName().contains('\\')) {
|
|
ShowError("Project name cannot contain path separators.");
|
|
return;
|
|
}
|
|
QDialog::accept();
|
|
}
|
|
|
|
private:
|
|
void ShowError(const QString& text) {
|
|
ErrorLabel_->setText(text);
|
|
ErrorLabel_->show();
|
|
}
|
|
|
|
void UpdateCreateButtonState() {
|
|
if (CreateButton_ == nullptr) {
|
|
return;
|
|
}
|
|
|
|
const bool valid = !ProjectName().isEmpty() &&
|
|
!LocationEdit_->text().trimmed().isEmpty() &&
|
|
VersionCombo_->currentIndex() >= 0;
|
|
CreateButton_->setEnabled(valid);
|
|
if (valid) {
|
|
ErrorLabel_->hide();
|
|
}
|
|
}
|
|
|
|
QLineEdit* NameEdit_ = nullptr;
|
|
QLineEdit* LocationEdit_ = nullptr;
|
|
QComboBox* VersionCombo_ = nullptr;
|
|
QLabel* ErrorLabel_ = nullptr;
|
|
QPushButton* CreateButton_ = nullptr;
|
|
};
|
|
|
|
class MetaCoreLauncherWindow final : public QMainWindow {
|
|
public:
|
|
explicit MetaCoreLauncherWindow(QWidget* parent = nullptr)
|
|
: QMainWindow(parent),
|
|
ExecutableDirectory_(GetExecutableDirectory()),
|
|
DatabasePath_(GetDatabasePath()) {
|
|
setWindowTitle("MetaCore Hub");
|
|
resize(1080, 720);
|
|
setMinimumSize(960, 640);
|
|
setObjectName("launcherWindow");
|
|
|
|
LoadProjects();
|
|
BuildUi();
|
|
RefreshProjectList();
|
|
}
|
|
|
|
private:
|
|
void BuildUi() {
|
|
ApplyStyleSheet(true);
|
|
|
|
auto* central = new QWidget(this);
|
|
central->setObjectName("central");
|
|
setCentralWidget(central);
|
|
|
|
auto* rootLayout = new QHBoxLayout(central);
|
|
rootLayout->setContentsMargins(0, 0, 0, 0);
|
|
rootLayout->setSpacing(0);
|
|
|
|
rootLayout->addWidget(BuildSidebar());
|
|
|
|
Pages_ = new QStackedWidget();
|
|
Pages_->addWidget(BuildProjectsPage());
|
|
Pages_->addWidget(BuildInstallsPage());
|
|
rootLayout->addWidget(Pages_, 1);
|
|
}
|
|
|
|
QWidget* BuildSidebar() {
|
|
auto* sidebar = new QWidget();
|
|
sidebar->setObjectName("sidebar");
|
|
sidebar->setFixedWidth(220);
|
|
|
|
auto* layout = new QVBoxLayout(sidebar);
|
|
layout->setContentsMargins(18, 24, 18, 18);
|
|
layout->setSpacing(8);
|
|
|
|
auto* titleLabel = new QLabel("MetaCore");
|
|
titleLabel->setObjectName("hubTitle");
|
|
auto* subtitleLabel = CreateMutedLabel("Hub");
|
|
layout->addWidget(titleLabel);
|
|
layout->addWidget(subtitleLabel);
|
|
layout->addSpacing(28);
|
|
|
|
ProjectsNavButton_ = CreateButton("Projects", "navButton");
|
|
InstallsNavButton_ = CreateButton("Installs", "navButton");
|
|
ProjectsNavButton_->setProperty("selected", true);
|
|
layout->addWidget(ProjectsNavButton_);
|
|
layout->addWidget(InstallsNavButton_);
|
|
layout->addStretch(1);
|
|
|
|
auto* darkMode = new QCheckBox("Dark Mode");
|
|
darkMode->setChecked(true);
|
|
layout->addWidget(darkMode);
|
|
|
|
connect(ProjectsNavButton_, &QPushButton::clicked, this, [this]() {
|
|
SetPage(0);
|
|
});
|
|
connect(InstallsNavButton_, &QPushButton::clicked, this, [this]() {
|
|
SetPage(1);
|
|
});
|
|
connect(darkMode, &QCheckBox::toggled, this, [this](bool checked) {
|
|
ApplyStyleSheet(checked);
|
|
});
|
|
|
|
return sidebar;
|
|
}
|
|
|
|
QWidget* BuildProjectsPage() {
|
|
auto* page = new QWidget();
|
|
auto* layout = new QVBoxLayout(page);
|
|
layout->setContentsMargins(28, 24, 28, 24);
|
|
layout->setSpacing(14);
|
|
|
|
auto* headerLayout = new QHBoxLayout();
|
|
auto* titleLabel = new QLabel("Projects");
|
|
titleLabel->setObjectName("pageTitle");
|
|
headerLayout->addWidget(titleLabel);
|
|
headerLayout->addStretch(1);
|
|
|
|
auto* deleteButton = CreateButton("Delete", "dangerButton");
|
|
auto* newButton = CreateButton("+ New Project");
|
|
auto* launchButton = CreateButton("Launch", "primaryButton");
|
|
headerLayout->addWidget(deleteButton);
|
|
headerLayout->addWidget(newButton);
|
|
headerLayout->addWidget(launchButton);
|
|
layout->addLayout(headerLayout);
|
|
|
|
SearchEdit_ = new QLineEdit();
|
|
SearchEdit_->setPlaceholderText("Search projects...");
|
|
SearchEdit_->setFixedHeight(36);
|
|
layout->addWidget(SearchEdit_);
|
|
|
|
StatusLabel_ = CreateMutedLabel("");
|
|
StatusLabel_->hide();
|
|
layout->addWidget(StatusLabel_);
|
|
|
|
auto* scrollArea = new QScrollArea();
|
|
scrollArea->setObjectName("projectScroll");
|
|
scrollArea->setWidgetResizable(true);
|
|
scrollArea->setFrameShape(QFrame::NoFrame);
|
|
ProjectListContainer_ = new QWidget();
|
|
ProjectListLayout_ = new QVBoxLayout(ProjectListContainer_);
|
|
ProjectListLayout_->setContentsMargins(0, 0, 0, 0);
|
|
ProjectListLayout_->setSpacing(10);
|
|
ProjectListLayout_->addStretch(1);
|
|
scrollArea->setWidget(ProjectListContainer_);
|
|
layout->addWidget(scrollArea, 1);
|
|
|
|
connect(SearchEdit_, &QLineEdit::textChanged, this, [this]() {
|
|
RefreshProjectList();
|
|
});
|
|
connect(newButton, &QPushButton::clicked, this, [this]() {
|
|
CreateNewProject();
|
|
});
|
|
connect(deleteButton, &QPushButton::clicked, this, [this]() {
|
|
RemoveSelectedProject();
|
|
});
|
|
connect(launchButton, &QPushButton::clicked, this, [this]() {
|
|
LaunchSelectedProject();
|
|
});
|
|
|
|
return page;
|
|
}
|
|
|
|
QWidget* BuildInstallsPage() {
|
|
auto* page = new QWidget();
|
|
auto* layout = new QVBoxLayout(page);
|
|
layout->setContentsMargins(28, 24, 28, 24);
|
|
layout->setSpacing(14);
|
|
|
|
auto* headerLayout = new QHBoxLayout();
|
|
auto* titleLabel = new QLabel("Installs");
|
|
titleLabel->setObjectName("pageTitle");
|
|
headerLayout->addWidget(titleLabel);
|
|
headerLayout->addStretch(1);
|
|
auto* refreshButton = CreateButton("Refresh");
|
|
headerLayout->addWidget(refreshButton);
|
|
layout->addLayout(headerLayout);
|
|
|
|
layout->addWidget(BuildInstallCard("MetaCore Editor", "", "Locate", [this]() {
|
|
QDesktopServices::openUrl(QUrl::fromLocalFile(ToQString(ExecutableDirectory_)));
|
|
}, &EditorInstallStatusLabel_));
|
|
layout->addWidget(BuildInstallCard("MetaCore Player", "", "Locate", [this]() {
|
|
QDesktopServices::openUrl(QUrl::fromLocalFile(ToQString(ExecutableDirectory_)));
|
|
}, &PlayerInstallStatusLabel_));
|
|
layout->addWidget(BuildInstallCard("Cook / Package Tool", "", "Locate", [this]() {
|
|
QDesktopServices::openUrl(QUrl::fromLocalFile(ToQString(ExecutableDirectory_)));
|
|
}, &CookToolInstallStatusLabel_));
|
|
layout->addWidget(BuildInstallCard("Runtime Stack", "C++20 / Filament / ImGui / RmlUi / Cook", "Refresh", [this]() {
|
|
RefreshInstallsPage();
|
|
}));
|
|
layout->addStretch(1);
|
|
|
|
connect(refreshButton, &QPushButton::clicked, this, [this]() {
|
|
RefreshInstallsPage();
|
|
});
|
|
RefreshInstallsPage();
|
|
return page;
|
|
}
|
|
|
|
QWidget* BuildInstallCard(
|
|
const QString& title,
|
|
const QString& subtitle,
|
|
const QString& action,
|
|
std::function<void()> callback,
|
|
QLabel** subtitleLabelOut = nullptr
|
|
) {
|
|
auto* card = new QFrame();
|
|
card->setObjectName("installCard");
|
|
card->setMinimumHeight(78);
|
|
card->setMaximumHeight(78);
|
|
|
|
auto* layout = new QHBoxLayout(card);
|
|
layout->setContentsMargins(16, 12, 16, 12);
|
|
layout->setSpacing(12);
|
|
|
|
auto* textLayout = new QVBoxLayout();
|
|
textLayout->setSpacing(3);
|
|
auto* titleLabel = new QLabel(title);
|
|
titleLabel->setObjectName("projectName");
|
|
textLayout->addWidget(titleLabel);
|
|
QLabel* subtitleLabel = CreateMutedLabel(subtitle);
|
|
subtitleLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
|
textLayout->addWidget(subtitleLabel);
|
|
if (subtitleLabelOut != nullptr) {
|
|
*subtitleLabelOut = subtitleLabel;
|
|
}
|
|
layout->addLayout(textLayout, 1);
|
|
|
|
auto* button = CreateButton(action);
|
|
button->setFixedWidth(90);
|
|
connect(button, &QPushButton::clicked, this, std::move(callback));
|
|
layout->addWidget(button);
|
|
return card;
|
|
}
|
|
|
|
void RefreshInstallsPage() {
|
|
auto describeExecutable = [this](const QString& label, const std::filesystem::path& executablePath) {
|
|
if (std::filesystem::is_regular_file(executablePath)) {
|
|
return QString("Installed: %1").arg(ToQString(executablePath));
|
|
}
|
|
return QString("Missing: %1 was not found next to the launcher. Build the %2 target first.")
|
|
.arg(ToQString(executablePath))
|
|
.arg(label);
|
|
};
|
|
|
|
if (EditorInstallStatusLabel_ != nullptr) {
|
|
const QString status = describeExecutable("MetaCoreEditorApp", ExecutableDirectory_ / "MetaCoreEditorApp.exe");
|
|
EditorInstallStatusLabel_->setText(status);
|
|
EditorInstallStatusLabel_->setToolTip(status);
|
|
}
|
|
if (PlayerInstallStatusLabel_ != nullptr) {
|
|
const QString status = describeExecutable("MetaCorePlayer", ExecutableDirectory_ / "MetaCorePlayer.exe");
|
|
PlayerInstallStatusLabel_->setText(status);
|
|
PlayerInstallStatusLabel_->setToolTip(status);
|
|
}
|
|
if (CookToolInstallStatusLabel_ != nullptr) {
|
|
const QString status = describeExecutable("MetaCoreBuildPackageTool", ExecutableDirectory_ / "MetaCoreBuildPackageTool.exe");
|
|
CookToolInstallStatusLabel_->setText(status);
|
|
CookToolInstallStatusLabel_->setToolTip(status);
|
|
}
|
|
}
|
|
|
|
void SetPage(int index) {
|
|
Pages_->setCurrentIndex(index);
|
|
ProjectsNavButton_->setProperty("selected", index == 0);
|
|
InstallsNavButton_->setProperty("selected", index == 1);
|
|
ProjectsNavButton_->style()->unpolish(ProjectsNavButton_);
|
|
ProjectsNavButton_->style()->polish(ProjectsNavButton_);
|
|
InstallsNavButton_->style()->unpolish(InstallsNavButton_);
|
|
InstallsNavButton_->style()->polish(InstallsNavButton_);
|
|
if (index == 1) {
|
|
RefreshInstallsPage();
|
|
}
|
|
}
|
|
|
|
void LoadProjects() {
|
|
Projects_.clear();
|
|
std::ifstream stream(DatabasePath_, std::ios::binary);
|
|
if (!stream.is_open()) {
|
|
return;
|
|
}
|
|
|
|
std::string line;
|
|
while (std::getline(stream, line)) {
|
|
std::stringstream lineStream(line);
|
|
std::string name;
|
|
std::string path;
|
|
std::string date;
|
|
if (!std::getline(lineStream, name, '\t') ||
|
|
!std::getline(lineStream, path, '\t') ||
|
|
!std::getline(lineStream, date, '\t')) {
|
|
continue;
|
|
}
|
|
|
|
LauncherProject project;
|
|
project.Name = QString::fromStdString(name);
|
|
project.RootPath = std::filesystem::path(path);
|
|
project.UpdatedDate = QString::fromStdString(date);
|
|
if (IsProjectRoot(project.RootPath)) {
|
|
Projects_.push_back(std::move(project));
|
|
}
|
|
}
|
|
if (!Projects_.empty()) {
|
|
SelectedProjectIndex_ = 0;
|
|
}
|
|
}
|
|
|
|
void SaveProjects() const {
|
|
std::error_code error;
|
|
std::filesystem::create_directories(DatabasePath_.parent_path(), error);
|
|
std::ofstream stream(DatabasePath_, std::ios::binary | std::ios::trunc);
|
|
if (!stream.is_open()) {
|
|
return;
|
|
}
|
|
|
|
for (const LauncherProject& project : Projects_) {
|
|
stream << project.Name.toStdString() << '\t'
|
|
<< project.RootPath.string() << '\t'
|
|
<< project.UpdatedDate.toStdString() << '\n';
|
|
}
|
|
}
|
|
|
|
void RefreshProjectList() {
|
|
ClearLayout(ProjectListLayout_);
|
|
|
|
const QString searchText = SearchEdit_ != nullptr ? SearchEdit_->text().trimmed().toLower() : QString();
|
|
for (int index = 0; index < static_cast<int>(Projects_.size()); ++index) {
|
|
const LauncherProject& project = Projects_[static_cast<std::size_t>(index)];
|
|
if (!searchText.isEmpty() &&
|
|
!project.Name.toLower().contains(searchText) &&
|
|
!ToQString(project.RootPath).toLower().contains(searchText)) {
|
|
continue;
|
|
}
|
|
|
|
auto* card = new ProjectCard(project);
|
|
card->SetSelected(index == SelectedProjectIndex_);
|
|
card->OnSelected = [this, index]() {
|
|
SelectedProjectIndex_ = index;
|
|
RefreshProjectList();
|
|
};
|
|
ProjectListLayout_->addWidget(card);
|
|
}
|
|
ProjectListLayout_->addStretch(1);
|
|
}
|
|
|
|
void CreateNewProject() {
|
|
NewProjectDialog dialog(this);
|
|
if (dialog.exec() != QDialog::Accepted) {
|
|
return;
|
|
}
|
|
|
|
if (FindProjectIndexByName(dialog.ProjectName()) >= 0) {
|
|
QMessageBox::critical(this, "Duplicate Name", QString("Project '%1' already exists.").arg(dialog.ProjectName()));
|
|
return;
|
|
}
|
|
|
|
QString errorMessage;
|
|
const std::filesystem::path projectRoot = dialog.ProjectRootPath();
|
|
if (!CreateProjectOnDisk(projectRoot, dialog.ProjectName(), errorMessage)) {
|
|
QMessageBox::warning(this, "Create New Project", errorMessage);
|
|
return;
|
|
}
|
|
|
|
LauncherProject project;
|
|
project.Name = dialog.ProjectName();
|
|
project.RootPath = projectRoot;
|
|
project.UpdatedDate = QDate::currentDate().toString(Qt::ISODate);
|
|
Projects_.insert(Projects_.begin(), std::move(project));
|
|
SelectedProjectIndex_ = 0;
|
|
SaveProjects();
|
|
RefreshProjectList();
|
|
ShowStatus("Created " + dialog.ProjectName());
|
|
}
|
|
|
|
void RemoveSelectedProject() {
|
|
if (!HasSelectedProject()) {
|
|
QMessageBox::warning(this, "No Selection", "Please select a project to delete.");
|
|
return;
|
|
}
|
|
|
|
const LauncherProject& project = Projects_[static_cast<std::size_t>(SelectedProjectIndex_)];
|
|
QString deleteSafetyError;
|
|
const bool projectFolderExists = std::filesystem::exists(project.RootPath);
|
|
if (projectFolderExists && !IsSafeProjectRootForDeletion(project.RootPath, deleteSafetyError)) {
|
|
QMessageBox::warning(this, "Delete Project", deleteSafetyError);
|
|
return;
|
|
}
|
|
|
|
const QMessageBox::StandardButton result = QMessageBox::question(
|
|
this,
|
|
"Confirm Deletion",
|
|
QString("Delete project '%1' and remove its folder from disk?\n\n%2")
|
|
.arg(project.Name, ToQString(project.RootPath))
|
|
);
|
|
if (result != QMessageBox::Yes) {
|
|
return;
|
|
}
|
|
|
|
if (projectFolderExists) {
|
|
std::error_code error;
|
|
std::filesystem::remove_all(project.RootPath, error);
|
|
if (error) {
|
|
QMessageBox::critical(
|
|
this,
|
|
"Project Deletion Failed",
|
|
QString("Failed to remove the project folder:\n%1\n\n%2")
|
|
.arg(ToQString(project.RootPath))
|
|
.arg(QString::fromStdString(error.message()))
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const QString deletedName = project.Name;
|
|
Projects_.erase(Projects_.begin() + SelectedProjectIndex_);
|
|
SelectedProjectIndex_ = std::min(SelectedProjectIndex_, static_cast<int>(Projects_.size()) - 1);
|
|
SaveProjects();
|
|
RefreshProjectList();
|
|
ShowStatus(projectFolderExists ? "Deleted " + deletedName : "Removed missing project from Hub.");
|
|
}
|
|
|
|
void LaunchSelectedProject() {
|
|
if (!HasSelectedProject()) {
|
|
ShowStatus("Select a project first.");
|
|
return;
|
|
}
|
|
|
|
LauncherProject& project = Projects_[static_cast<std::size_t>(SelectedProjectIndex_)];
|
|
const std::filesystem::path editorPath = ExecutableDirectory_ / "MetaCoreEditorApp.exe";
|
|
if (!std::filesystem::is_regular_file(editorPath)) {
|
|
QMessageBox::warning(this, "Launch", "MetaCoreEditorApp.exe was not found next to the launcher.");
|
|
return;
|
|
}
|
|
|
|
QProcess process;
|
|
process.setProgram(ToQString(editorPath));
|
|
process.setWorkingDirectory(ToQString(ExecutableDirectory_));
|
|
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
|
|
environment.insert("METACORE_PROJECT_PATH", ToQString(project.RootPath));
|
|
process.setProcessEnvironment(environment);
|
|
|
|
if (!process.startDetached()) {
|
|
QMessageBox::warning(this, "Launch", "Unable to launch MetaCoreEditorApp.exe.");
|
|
return;
|
|
}
|
|
|
|
project.UpdatedDate = QDate::currentDate().toString(Qt::ISODate);
|
|
SaveProjects();
|
|
RefreshProjectList();
|
|
ShowStatus("Launched " + project.Name);
|
|
}
|
|
|
|
bool HasSelectedProject() const {
|
|
return SelectedProjectIndex_ >= 0 && SelectedProjectIndex_ < static_cast<int>(Projects_.size());
|
|
}
|
|
|
|
int FindProjectIndexByName(const QString& name) const {
|
|
for (int index = 0; index < static_cast<int>(Projects_.size()); ++index) {
|
|
if (Projects_[static_cast<std::size_t>(index)].Name.compare(name, Qt::CaseInsensitive) == 0) {
|
|
return index;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
bool IsSafeProjectRootForDeletion(const std::filesystem::path& rootPath, QString& errorMessage) const {
|
|
std::error_code error;
|
|
const std::filesystem::path canonical = std::filesystem::weakly_canonical(rootPath, error);
|
|
const std::filesystem::path checkedPath = error ? rootPath.lexically_normal() : canonical;
|
|
|
|
if (!std::filesystem::is_directory(rootPath)) {
|
|
errorMessage = "Selected project path is not a folder.";
|
|
return false;
|
|
}
|
|
if (!IsProjectRoot(rootPath)) {
|
|
errorMessage = "Selected folder does not contain MetaCore.project.json, so it will not be deleted.";
|
|
return false;
|
|
}
|
|
if (checkedPath == checkedPath.root_path()) {
|
|
errorMessage = "Refusing to delete a filesystem root.";
|
|
return false;
|
|
}
|
|
if (checkedPath.parent_path() == checkedPath) {
|
|
errorMessage = "Refusing to delete an invalid project path.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
void ShowStatus(const QString& text) {
|
|
if (StatusLabel_ == nullptr) {
|
|
return;
|
|
}
|
|
StatusLabel_->setText(text);
|
|
StatusLabel_->show();
|
|
}
|
|
|
|
void ApplyStyleSheet(bool darkMode) {
|
|
if (!darkMode) {
|
|
qApp->setStyleSheet({});
|
|
return;
|
|
}
|
|
|
|
qApp->setStyleSheet(R"(
|
|
QMainWindow#launcherWindow, QWidget#central {
|
|
background: #191919;
|
|
color: #cfcfcf;
|
|
font-size: 13px;
|
|
}
|
|
QWidget#sidebar {
|
|
background: #141414;
|
|
border-right: 1px solid #222222;
|
|
}
|
|
QLabel#hubTitle {
|
|
color: #f0f0f0;
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
}
|
|
QLabel#pageTitle {
|
|
color: #f0f0f0;
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
}
|
|
QLabel#mutedText {
|
|
color: #707070;
|
|
}
|
|
QLabel#errorText {
|
|
color: #eb5757;
|
|
}
|
|
QLabel#projectName {
|
|
color: #e5e5e5;
|
|
font-weight: 600;
|
|
}
|
|
QLabel#projectAvatar {
|
|
background: #333333;
|
|
color: #f1f1f1;
|
|
border-radius: 6px;
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
}
|
|
QPushButton {
|
|
background: #202020;
|
|
color: #cfcfcf;
|
|
border: 1px solid #2f2f2f;
|
|
border-radius: 4px;
|
|
padding: 7px 12px;
|
|
}
|
|
QPushButton:hover {
|
|
background: #2a2a2a;
|
|
}
|
|
QPushButton:pressed, QPushButton[selected="true"] {
|
|
background: #333333;
|
|
}
|
|
QPushButton#primaryButton {
|
|
background: #f1f1f1;
|
|
color: #151515;
|
|
border-color: #f1f1f1;
|
|
}
|
|
QPushButton#primaryButton:hover {
|
|
background: #ffffff;
|
|
}
|
|
QPushButton#primaryButton:disabled {
|
|
background: #555555;
|
|
color: #9a9a9a;
|
|
border-color: #555555;
|
|
}
|
|
QPushButton#dangerButton {
|
|
background: #2a2020;
|
|
color: #f2b7b7;
|
|
border-color: #4a2d2d;
|
|
}
|
|
QPushButton#dangerButton:hover {
|
|
background: #3a2424;
|
|
}
|
|
QPushButton#navButton {
|
|
text-align: left;
|
|
min-height: 36px;
|
|
border: none;
|
|
padding-left: 12px;
|
|
background: #141414;
|
|
}
|
|
QPushButton#navButton:hover {
|
|
background: #1e1e1e;
|
|
}
|
|
QPushButton#navButton[selected="true"] {
|
|
background: #252525;
|
|
}
|
|
QPushButton#smallButton {
|
|
padding: 5px 8px;
|
|
}
|
|
QLineEdit, QComboBox {
|
|
background: #202020;
|
|
color: #d8d8d8;
|
|
border: 1px solid #2f2f2f;
|
|
border-radius: 4px;
|
|
padding: 8px 10px;
|
|
}
|
|
QLineEdit:focus, QComboBox:focus {
|
|
border-color: #555555;
|
|
}
|
|
QLineEdit:read-only {
|
|
color: #b8b8b8;
|
|
}
|
|
QFrame#projectCard, QFrame#installCard {
|
|
background: #202020;
|
|
border: 1px solid #2f2f2f;
|
|
border-radius: 6px;
|
|
}
|
|
QFrame#projectCard:hover {
|
|
background: #2a2a2a;
|
|
}
|
|
QFrame#projectCard[selected="true"] {
|
|
background: #333333;
|
|
border-color: #4a4a4a;
|
|
}
|
|
QScrollArea#projectScroll {
|
|
background: transparent;
|
|
}
|
|
QScrollArea#projectScroll > QWidget > QWidget {
|
|
background: transparent;
|
|
}
|
|
QDialog {
|
|
background: #191919;
|
|
color: #cfcfcf;
|
|
}
|
|
QMessageBox {
|
|
background: #191919;
|
|
color: #cfcfcf;
|
|
}
|
|
QCheckBox {
|
|
color: #cfcfcf;
|
|
}
|
|
)");
|
|
}
|
|
|
|
std::vector<LauncherProject> Projects_{};
|
|
int SelectedProjectIndex_ = -1;
|
|
std::filesystem::path ExecutableDirectory_{};
|
|
std::filesystem::path DatabasePath_{};
|
|
|
|
QStackedWidget* Pages_ = nullptr;
|
|
QPushButton* ProjectsNavButton_ = nullptr;
|
|
QPushButton* InstallsNavButton_ = nullptr;
|
|
QLineEdit* SearchEdit_ = nullptr;
|
|
QLabel* StatusLabel_ = nullptr;
|
|
QLabel* EditorInstallStatusLabel_ = nullptr;
|
|
QLabel* PlayerInstallStatusLabel_ = nullptr;
|
|
QLabel* CookToolInstallStatusLabel_ = nullptr;
|
|
QWidget* ProjectListContainer_ = nullptr;
|
|
QVBoxLayout* ProjectListLayout_ = nullptr;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char* argv[]) {
|
|
QApplication app(argc, argv);
|
|
QApplication::setApplicationName(ApplicationName);
|
|
QApplication::setOrganizationName(OrganizationName);
|
|
QApplication::setApplicationVersion("1.0.0");
|
|
|
|
MetaCoreLauncherWindow window;
|
|
window.show();
|
|
return app.exec();
|
|
}
|