diff --git a/atlas/application/window.cpp b/atlas/application/window.cpp index 9eca9357..01f6a7db 100644 --- a/atlas/application/window.cpp +++ b/atlas/application/window.cpp @@ -1870,8 +1870,11 @@ bool Window::stepFrame() { updatePipelineStateField(this->useBlending, true); - for (auto &obj : this->uiRenderables) { - obj->render(getDeltaTime(), commandBuffer, shouldRefreshPipeline(obj)); + if (!editorControlsEnabled || editorCameraFocused) { + for (auto &obj : this->uiRenderables) { + obj->render(getDeltaTime(), commandBuffer, + shouldRefreshPipeline(obj)); + } } this->lastViewMatrix = screenView; diff --git a/editor/views/editor/editor.cpp b/editor/views/editor/editor.cpp index f9a6d41a..2486a361 100644 --- a/editor/views/editor/editor.cpp +++ b/editor/views/editor/editor.cpp @@ -80,6 +80,7 @@ #include "editor/views/inspectorView.h" #include "editor/views/inputActionsDialog.h" #include "editor/views/materialEditor.h" +#include "editor/views/graphiteEditor.h" #include "editor/views/postProcessing.h" #include "editor/views/viewport.h" #include "editor/views/viewportTools.h" @@ -306,6 +307,10 @@ void EditorWindow::setupMenus() { return action; }; addCommand(fileMenu, "New Scene", "Meta+N", [this] { createScene(); }); + addCommand(fileMenu, "New Graphite UI", QString(), [this] { + if (contentBrowser != nullptr) + contentBrowser->createUI(); + }); addCommand(fileMenu, "Open Scene…", "Meta+O", [this] { openScene(); }); auto *saveAction = fileMenu->addAction("Save Scene"); saveAction->setIcon(styling::icon(styling::Icon::FloppyDisk, "#A1957D")); @@ -316,6 +321,10 @@ void EditorWindow::setupMenus() { materialEditorPanel->isVisible()) { materialEditorPanel->saveMaterial(); } + if (graphiteEditorPanel != nullptr && + graphiteEditorPanel->isVisible()) { + graphiteEditorPanel->saveUI(); + } if (viewportPanel != nullptr) { viewportPanel->saveRuntimeScene(); } @@ -352,6 +361,10 @@ void EditorWindow::setupMenus() { materialEditorPanel->isAncestorOf( QApplication::focusWidget())) { materialEditorPanel->undo(); + } else if (graphiteEditorPanel != nullptr && + graphiteEditorPanel->isAncestorOf( + QApplication::focusWidget())) { + graphiteEditorPanel->undo(); } else if (viewportPanel != nullptr) { viewportPanel->undo(); } @@ -369,6 +382,10 @@ void EditorWindow::setupMenus() { materialEditorPanel->isAncestorOf( QApplication::focusWidget())) { materialEditorPanel->redo(); + } else if (graphiteEditorPanel != nullptr && + graphiteEditorPanel->isAncestorOf( + QApplication::focusWidget())) { + graphiteEditorPanel->redo(); } else if (viewportPanel != nullptr) { viewportPanel->redo(); } @@ -599,11 +616,14 @@ void EditorWindow::setupDocks() { viewportTools = new ViewportTools(viewportPanel, projectFile); materialEditorPanel = new MaterialEditorPanel(viewportPanel); postProcessingPanel = new PostProcessingPanel(viewportPanel); + graphiteEditorPanel = + new GraphiteEditorPanel(viewportPanel, projectFile); workspaceStack = new QStackedWidget(this); workspaceStack->setObjectName("editorWorkspaceStack"); workspaceStack->addWidget(viewportTools); workspaceStack->addWidget(materialEditorPanel); workspaceStack->addWidget(postProcessingPanel); + workspaceStack->addWidget(graphiteEditorPanel); workspaceStack->setCurrentIndex(0); auto *workspaceDock = dockManager->addPanel( {.id = "workspace", @@ -682,7 +702,10 @@ void EditorWindow::setupDocks() { } windowMenu->addSeparator(); const QList> workspaceModes{ - {"Scene", 0}, {"Shading", 1}, {"Post-Processing", 2}}; + {"Scene", 0}, + {"Shading", 1}, + {"Post-Processing", 2}, + {"Graphite", 3}}; for (const auto &[name, index] : workspaceModes) { auto *action = windowMenu->addAction( QStringLiteral("Open %1 Workspace").arg(name), this, @@ -696,7 +719,9 @@ void EditorWindow::setupDocks() { index == 0 ? styling::icon(styling::Icon::CubeFocus, "#7E929C") : index == 1 ? styling::icon(styling::Icon::Material, "#A1957D") - : styling::icon(styling::Icon::FilmStrip, "#849589")); + : index == 2 + ? styling::icon(styling::Icon::FilmStrip, "#849589") + : styling::icon(styling::Icon::Palette, "#849589")); } } @@ -706,6 +731,21 @@ void EditorWindow::setupDocks() { &InspectorPanel::inspectCamera); connect(hierarchyPanel, &HierarchyPanel::environmentActivated, inspectorPanel, &InspectorPanel::inspectEnvironment); + connect(hierarchyPanel, &HierarchyPanel::graphiteActivated, this, + [this](const QString &path) { + if (!path.isEmpty() && graphiteEditorPanel != nullptr && + viewportPanel != nullptr) { + QString resolved = path; + if (QFileInfo(resolved).isRelative()) { + resolved = + QDir(QFileInfo(viewportPanel->currentRuntimeScene()) + .absolutePath()) + .filePath(resolved); + } + graphiteEditorPanel->openUI(resolved); + } + activateWorkspace(3); + }); connect(hierarchyPanel, &HierarchyPanel::objectActivated, contentBrowser, &ContentBrowserPanel::clearSelection); connect(viewportPanel, &ViewportPanel::runtimeObjectActivated, @@ -734,6 +774,13 @@ void EditorWindow::setupDocks() { viewportTools->openSceneTab(path); } }); + connect(contentBrowser, &ContentBrowserPanel::uiActivated, this, + [this](const QString &path) { + graphiteEditorPanel->openUI(path); + activateWorkspace(3); + }); + connect(graphiteEditorPanel, &GraphiteEditorPanel::previewRequested, this, + [this] { activateWorkspace(0); }); } void EditorWindow::setupWorkspaceBar() { @@ -782,6 +829,7 @@ void EditorWindow::setupWorkspaceBar() { addMode("Scene", styling::Icon::CubeFocus, "#7E929C", 0, true); addMode("Shading", styling::Icon::Material, "#A1957D", 1); addMode("Post-Processing", styling::Icon::FilmStrip, "#849589", 2); + addMode("Graphite", styling::Icon::Palette, "#849589", 3); auto *spacer = new QWidget(bar); spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); @@ -795,6 +843,8 @@ void EditorWindow::setupWorkspaceBar() { connect(save, &QToolButton::clicked, this, [this] { if (materialEditorPanel != nullptr && materialEditorPanel->isVisible()) materialEditorPanel->saveMaterial(); + if (graphiteEditorPanel != nullptr && graphiteEditorPanel->isVisible()) + graphiteEditorPanel->saveUI(); if (viewportPanel != nullptr) viewportPanel->saveRuntimeScene(); }); diff --git a/editor/views/editor/graphiteEditor.cpp b/editor/views/editor/graphiteEditor.cpp new file mode 100644 index 00000000..998e8f89 --- /dev/null +++ b/editor/views/editor/graphiteEditor.cpp @@ -0,0 +1,1391 @@ +#include "editor/views/graphiteEditor.h" + +#include "editor/styling/icons.h" +#include "editor/views/viewport.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +QString pathKey(const QList &path) { + QStringList parts; + for (int index : path) + parts.append(QString::number(index)); + return parts.join('/'); +} + +QColor jsonColor(const QJsonValue &value, const QColor &fallback) { + const QJsonArray values = value.toArray(); + if (values.size() < 3) + return fallback; + double maximum = std::max( + {values.at(0).toDouble(), values.at(1).toDouble(), + values.at(2).toDouble(), + values.size() > 3 ? values.at(3).toDouble() : 1.0}); + const double scale = maximum > 1.0 ? 255.0 : 1.0; + return QColor::fromRgbF( + std::clamp(values.at(0).toDouble() / scale, 0.0, 1.0), + std::clamp(values.at(1).toDouble() / scale, 0.0, 1.0), + std::clamp(values.at(2).toDouble() / scale, 0.0, 1.0), + std::clamp((values.size() > 3 ? values.at(3).toDouble() : scale) / + scale, + 0.0, 1.0)); +} + +QJsonArray colorJson(const QColor &color) { + return {color.redF(), color.greenF(), color.blueF(), color.alphaF()}; +} + +QColor chooseColor(QWidget *parent, const QColor &initial, + const QString &title) { + QColorDialog dialog(initial, parent); + dialog.setWindowTitle(title); + dialog.setOption(QColorDialog::ShowAlphaChannel); + dialog.setOption(QColorDialog::DontUseNativeDialog); + if (dialog.exec() == QDialog::Rejected) + return {}; + return dialog.selectedColor(); +} + +bool isGraphiteColorField(const QString &key) { + return key.compare("background", Qt::CaseInsensitive) == 0 || + key.compare("foreground", Qt::CaseInsensitive) == 0 || + key.compare("border", Qt::CaseInsensitive) == 0 || + key.compare("tint", Qt::CaseInsensitive) == 0 || + key.compare("color", Qt::CaseInsensitive) == 0 || + key.endsWith("Color", Qt::CaseInsensitive); +} + +QJsonValue repairGraphiteColors(const QJsonValue &value, const QString &key, + bool &changed) { + if (value.isObject()) { + QJsonObject object = value.toObject(); + for (auto iterator = object.begin(); iterator != object.end(); ++iterator) + iterator.value() = + repairGraphiteColors(iterator.value(), iterator.key(), changed); + return object; + } + if (!value.isArray()) + return value; + QJsonArray array = value.toArray(); + if (isGraphiteColorField(key) && array.size() == 4 && + std::abs(array.at(3).toDouble() - (1.0 / 255.0)) < 0.00001) { + array[3] = 1.0; + changed = true; + } + for (int index = 0; index < array.size(); ++index) + array[index] = repairGraphiteColors(array.at(index), {}, changed); + return array; +} + +QString ensureGraphiteDefaultFont(const QString &projectRoot) { + QDir root(projectRoot); + if (!root.mkpath("assets/fonts")) + return {}; + const QString path = root.filePath("assets/fonts/GraphiteDefault.ttf"); + if (QFileInfo::exists(path)) + return path; + QFile source(":/editor/assets/Manrope-VariableFont_wght.ttf"); + QSaveFile destination(path); + if (!source.open(QIODevice::ReadOnly) || + !destination.open(QIODevice::WriteOnly)) + return {}; + const QByteArray contents = source.readAll(); + if (destination.write(contents) != contents.size() || + !destination.commit()) + return {}; + return path; +} + +QPointF jsonPoint(const QJsonValue &value, const QPointF &fallback = {}) { + const QJsonArray values = value.toArray(); + if (values.size() != 2) + return fallback; + return {values.at(0).toDouble(), values.at(1).toDouble()}; +} + +QSizeF jsonSize(const QJsonObject &element) { + const QString type = element.value("type").toString(); + const QJsonArray values = element.value("size").toArray(); + if (values.size() == 2) + return {std::max(1.0, values.at(0).toDouble()), + std::max(1.0, values.at(1).toDouble())}; + if (type == "text") + return {std::max(80.0, + element.value("content").toString().size() * + element.value("fontSize").toDouble(24.0) * 0.58), + std::max(32.0, element.value("fontSize").toDouble(24.0) * + 1.35)}; + if (type == "checkbox") + return {220, 44}; + if (type == "textField") + return {320, 48}; + if (type == "image") + return {160, 120}; + if (type == "column" || type == "row" || type == "stack") + return {360, 220}; + return {180, 48}; +} + +QJsonObject replaceAtPath(QJsonObject document, const QList &path, + const QJsonObject *replacement, bool remove) { + if (path.isEmpty()) + return document; + std::function replaceArray; + replaceArray = [&](QJsonArray array, int depth) { + const int index = path.at(depth); + if (index < 0 || index >= array.size()) + return array; + if (depth == path.size() - 1) { + if (remove) + array.removeAt(index); + else if (replacement != nullptr) + array[index] = *replacement; + return array; + } + QJsonObject parent = array.at(index).toObject(); + parent.insert("children", + replaceArray(parent.value("children").toArray(), + depth + 1)); + array[index] = parent; + return array; + }; + document.insert("elements", + replaceArray(document.value("elements").toArray(), 0)); + return document; +} + +class GraphiteDocumentCommand : public QUndoCommand { + public: + GraphiteDocumentCommand(QJsonObject before, QJsonObject after, + std::function apply) + : before(std::move(before)), after(std::move(after)), + apply(std::move(apply)) {} + + void undo() override { apply(before); } + void redo() override { apply(after); } + + private: + QJsonObject before; + QJsonObject after; + std::function apply; +}; + +} + +class GraphiteCanvas : public QWidget { + public: + explicit GraphiteCanvas(QWidget *parent = nullptr) : QWidget(parent) { + setMinimumSize(460, 320); + setMouseTracking(true); + setFocusPolicy(Qt::StrongFocus); + } + + void setDocument(const QJsonObject &next, const QString &path) { + document = next; + baseDir = QFileInfo(path).absolutePath(); + update(); + } + + void setSelectedPath(const QList &path) { + selected = path; + update(); + } + + std::function &)> selectionChanged; + std::function &, const QPointF &)> elementMoved; + + protected: + void paintEvent(QPaintEvent *) override { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + painter.fillRect(rect(), QColor("#17191D")); + const QJsonObject canvas = document.value("canvas").toObject(); + const double width = std::max(1.0, canvas.value("width").toDouble(1280)); + const double height = + std::max(1.0, canvas.value("height").toDouble(720)); + const double scale = std::min((this->width() - 48.0) / width, + (this->height() - 48.0) / height); + canvasScale = std::max(0.05, scale); + const QSizeF shown(width * canvasScale, height * canvasScale); + canvasRect = QRectF((this->width() - shown.width()) * 0.5, + (this->height() - shown.height()) * 0.5, + shown.width(), shown.height()); + painter.fillRect( + canvasRect, + jsonColor(canvas.value("background"), QColor("#20242C"))); + painter.setPen(QPen(QColor(255, 255, 255, 35), 1)); + painter.drawRect(canvasRect); + painter.save(); + painter.translate(canvasRect.topLeft()); + painter.scale(canvasScale, canvasScale); + bounds.clear(); + paintOrder.clear(); + const QJsonArray elements = document.value("elements").toArray(); + for (int index = 0; index < elements.size(); ++index) + drawElement(painter, elements.at(index).toObject(), {index}, {}); + painter.restore(); + painter.setPen(QColor(255, 255, 255, 90)); + painter.drawText(canvasRect.adjusted(8, 6, -8, -6), + Qt::AlignRight | Qt::AlignBottom, + QStringLiteral("%1 × %2 · %3%") + .arg(width, 0, 'f', 0) + .arg(height, 0, 'f', 0) + .arg(canvasScale * 100.0, 0, 'f', 0)); + } + + void mousePressEvent(QMouseEvent *event) override { + if (event->button() != Qt::LeftButton || !canvasRect.contains(event->position())) + return; + const QPointF point = + (event->position() - canvasRect.topLeft()) / canvasScale; + QList hit; + for (auto it = paintOrder.crbegin(); it != paintOrder.crend(); ++it) { + if (bounds.value(pathKey(*it)).contains(point)) { + hit = *it; + break; + } + } + selected = hit; + dragging = !hit.isEmpty() && hit.size() == 1; + dragStart = point; + if (dragging) { + const QJsonArray elements = document.value("elements").toArray(); + dragOrigin = jsonPoint(elements.at(hit.first()).toObject().value("position")); + } + if (selectionChanged) + selectionChanged(hit); + update(); + } + + void mouseMoveEvent(QMouseEvent *event) override { + if (!dragging || selected.isEmpty() || !elementMoved) + return; + const QPointF point = + (event->position() - canvasRect.topLeft()) / canvasScale; + elementMoved(selected, dragOrigin + point - dragStart); + } + + void mouseReleaseEvent(QMouseEvent *) override { dragging = false; } + + private: + QRectF drawElement(QPainter &painter, const QJsonObject &element, + const QList &path, const QRectF &assigned) { + const QString type = element.value("type").toString(); + const QSizeF size = jsonSize(element); + const QPointF position = assigned.isValid() + ? assigned.topLeft() + : jsonPoint(element.value("position")); + QRectF frame(position, assigned.isValid() ? assigned.size() : size); + bounds.insert(pathKey(path), frame); + paintOrder.append(path); + const QJsonObject normal = + element.value("style").toObject().value("normal").toObject(); + QColor background = jsonColor(normal.value("background"), + QColor(48, 52, 64, 235)); + QColor foreground = jsonColor( + normal.value("foreground"), + jsonColor(element.value("color"), QColor("#F5F6F8"))); + const double radius = normal.value("cornerRadius").toDouble(8.0); + painter.save(); + if (type == "text") { + QFont font = painter.font(); + font.setPixelSize(std::max(8, element.value("fontSize").toInt(24))); + painter.setFont(font); + painter.setPen(foreground); + painter.drawText(frame, Qt::AlignLeft | Qt::AlignVCenter, + element.value("content").toString("Text")); + } else if (type == "checkbox") { + painter.setPen(QPen(QColor(255, 255, 255, 55), 1)); + painter.setBrush(QColor(33, 36, 44)); + const double box = std::min(frame.height() - 12.0, 24.0); + QRectF check(frame.left() + 6, frame.center().y() - box * 0.5, box, + box); + painter.drawRoundedRect(check, 4, 4); + if (element.value("checked").toBool()) { + painter.setPen(QPen(QColor("#E39758"), 3)); + painter.drawLine(check.left() + 5, check.center().y(), + check.center().x(), check.bottom() - 5); + painter.drawLine(check.center().x(), check.bottom() - 5, + check.right() - 4, check.top() + 5); + } + painter.setPen(foreground); + painter.drawText(frame.adjusted(box + 14, 0, 0, 0), + Qt::AlignLeft | Qt::AlignVCenter, + element.value("label").toString("Checkbox")); + } else if (type == "image") { + painter.setPen(QPen(QColor(255, 255, 255, 45), 1)); + painter.setBrush(QColor(37, 42, 52)); + painter.drawRoundedRect(frame, radius, radius); + const QString source = element.value("source").toString(); + QImage image(QDir(baseDir).filePath(source)); + if (!image.isNull()) + painter.drawImage(frame, image); + else { + painter.drawLine(frame.topLeft(), frame.bottomRight()); + painter.drawLine(frame.topRight(), frame.bottomLeft()); + painter.drawText(frame, Qt::AlignCenter, "Image"); + } + } else if (type == "column" || type == "row" || type == "stack") { + painter.setPen(QPen(QColor(126, 146, 156, 150), 1, + Qt::DashLine)); + painter.setBrush(background); + painter.drawRoundedRect(frame, radius, radius); + const QJsonArray children = element.value("children").toArray(); + const QPointF padding = jsonPoint(element.value("padding"), {12, 12}); + const double spacing = element.value("spacing").toDouble(8.0); + QRectF content = frame.adjusted(padding.x(), padding.y(), + -padding.x(), -padding.y()); + double cursor = type == "row" ? content.left() : content.top(); + for (int index = 0; index < children.size(); ++index) { + const QJsonObject child = children.at(index).toObject(); + QSizeF childSize = jsonSize(child); + QRectF childFrame; + if (type == "row") { + childFrame = QRectF(cursor, content.top(), childSize.width(), + std::min(childSize.height(), content.height())); + cursor += childSize.width() + spacing; + } else if (type == "column") { + childFrame = QRectF(content.left(), cursor, + std::min(childSize.width(), content.width()), + childSize.height()); + cursor += childSize.height() + spacing; + } else { + childFrame = QRectF(content.topLeft(), childSize); + } + QList childPath = path; + childPath.append(index); + drawElement(painter, child, childPath, childFrame); + } + } else { + painter.setPen(QPen(QColor(255, 255, 255, 52), 1)); + painter.setBrush(background); + painter.drawRoundedRect(frame, radius, radius); + painter.setPen(foreground); + const QString text = + type == "textField" + ? element.value("text").toString().isEmpty() + ? element.value("placeholder").toString("Text field") + : element.value("text").toString() + : element.value("label").toString("Button"); + painter.drawText(frame.adjusted(14, 0, -14, 0), + Qt::AlignCenter, text); + } + if (path == selected) { + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(QColor("#E39758"), 2.0 / canvasScale)); + painter.drawRect(frame.adjusted(-2, -2, 2, 2)); + } + if (!element.value("components").toArray().isEmpty()) { + painter.setPen(Qt::NoPen); + painter.setBrush(QColor("#849589")); + painter.drawEllipse(QPointF(frame.right() - 7, frame.top() + 7), 4, + 4); + } + painter.restore(); + return frame; + } + + QJsonObject document; + QString baseDir; + QList selected; + QHash bounds; + QList> paintOrder; + QRectF canvasRect; + double canvasScale = 1.0; + bool dragging = false; + QPointF dragStart; + QPointF dragOrigin; +}; + +GraphiteEditorPanel::GraphiteEditorPanel(ViewportPanel *viewport, + const QString &projectFile, + QWidget *parent) + : QWidget(parent), viewport(viewport), projectFile(projectFile) { + setObjectName("graphiteEditorPanel"); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + auto *header = new QFrame(this); + header->setObjectName("materialEditorHeader"); + auto *headerLayout = new QHBoxLayout(header); + headerLayout->setContentsMargins(14, 9, 12, 9); + titleLabel = new QLabel("Graphite", header); + titleLabel->setObjectName("materialEditorTitle"); + statusLabel = new QLabel(header); + statusLabel->setObjectName("materialEditorStatus"); + auto *attach = new QPushButton("Attach to Scene", header); + attach->setIcon(styling::icon(styling::Icon::Assign, "#9E897D")); + auto *preview = new QPushButton("Preview Camera", header); + preview->setIcon(styling::icon(styling::Icon::MonitorPlay, "#849589")); + auto *save = new QPushButton("Save", header); + save->setIcon(styling::icon(styling::Icon::FloppyDisk, "#A1957D")); + headerLayout->addWidget(titleLabel, 1); + headerLayout->addWidget(statusLabel); + headerLayout->addWidget(attach); + headerLayout->addWidget(preview); + headerLayout->addWidget(save); + layout->addWidget(header); + + auto *splitter = new QSplitter(Qt::Horizontal, this); + splitter->setChildrenCollapsible(false); + + auto *outline = new QWidget(splitter); + outline->setMinimumWidth(210); + auto *outlineLayout = new QVBoxLayout(outline); + outlineLayout->setContentsMargins(8, 8, 4, 8); + auto *outlineToolbar = new QHBoxLayout(); + auto *add = new QToolButton(outline); + add->setText("Add"); + add->setIcon(styling::icon(styling::Icon::Plus, "#8498A8")); + add->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + add->setPopupMode(QToolButton::InstantPopup); + auto *addMenu = new QMenu(add); + const QList> types{{"Text", "text"}, + {"Image", "image"}, + {"Button", "button"}, + {"Checkbox", "checkbox"}, + {"Text Field", "textField"}, + {"Column", "column"}, + {"Row", "row"}, + {"Stack", "stack"}}; + for (const auto &[label, type] : types) + addMenu->addAction(label, this, [this, type] { addElement(type); }); + add->setMenu(addMenu); + auto *duplicate = new QToolButton(outline); + duplicate->setIcon(styling::icon(styling::Icon::SquaresFour, "#7E929C")); + duplicate->setToolTip("Duplicate selected element"); + auto *remove = new QToolButton(outline); + remove->setIcon(styling::icon(styling::Icon::Trash, "#A17F7F")); + remove->setToolTip("Delete selected element"); + outlineToolbar->addWidget(add); + outlineToolbar->addStretch(); + outlineToolbar->addWidget(duplicate); + outlineToolbar->addWidget(remove); + outlineLayout->addLayout(outlineToolbar); + tree = new QTreeWidget(outline); + tree->setHeaderHidden(true); + tree->setSelectionMode(QAbstractItemView::SingleSelection); + tree->setUniformRowHeights(true); + outlineLayout->addWidget(tree, 1); + + canvas = new GraphiteCanvas(splitter); + + auto *inspectorScroll = new QScrollArea(splitter); + inspectorScroll->setWidgetResizable(true); + inspectorScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + inspectorScroll->setMinimumWidth(285); + inspectorBody = new QWidget(inspectorScroll); + inspectorLayout = new QVBoxLayout(inspectorBody); + inspectorLayout->setContentsMargins(8, 8, 8, 12); + inspectorLayout->setSpacing(8); + inspectorScroll->setWidget(inspectorBody); + + splitter->addWidget(outline); + splitter->addWidget(canvas); + splitter->addWidget(inspectorScroll); + splitter->setStretchFactor(0, 0); + splitter->setStretchFactor(1, 1); + splitter->setStretchFactor(2, 0); + splitter->setSizes({230, 760, 310}); + layout->addWidget(splitter, 1); + + undoStack = new QUndoStack(this); + connect(tree, &QTreeWidget::itemSelectionChanged, this, [this] { + canvas->setSelectedPath(selectedPath()); + rebuildInspector(); + }); + connect(save, &QPushButton::clicked, this, &GraphiteEditorPanel::saveUI); + connect(attach, &QPushButton::clicked, this, + [this] { attachToScene(false); }); + connect(preview, &QPushButton::clicked, this, + [this] { attachToScene(true); }); + connect(remove, &QToolButton::clicked, this, + &GraphiteEditorPanel::deleteSelectedElement); + connect(duplicate, &QToolButton::clicked, this, + &GraphiteEditorPanel::duplicateSelectedElement); + canvas->selectionChanged = [this](const QList &path) { + const QString key = pathKey(path); + const auto items = tree->findItems("*", Qt::MatchWildcard | + Qt::MatchRecursive); + for (QTreeWidgetItem *item : items) { + if (item->data(0, Qt::UserRole).toString() == key) { + tree->setCurrentItem(item); + return; + } + } + tree->clearSelection(); + }; + canvas->elementMoved = [this](const QList &path, + const QPointF &position) { + QJsonObject element = elementAtPath(path); + if (element.isEmpty()) + return; + element.insert("position", QJsonArray{std::round(position.x()), + std::round(position.y())}); + QJsonObject next = replaceAtPath(document, path, &element, false); + setDocument(next, true); + }; + showEmptyState(); +} + +GraphiteEditorPanel::~GraphiteEditorPanel() { + if (!uiPath.isEmpty()) + saveUI(); +} + +void GraphiteEditorPanel::showEmptyState() { + document = QJsonObject{{"format", "atlas.graphite.ui"}, + {"version", 1}, + {"canvas", QJsonObject{{"width", 1280}, + {"height", 720}, + {"background", QJsonArray{ + 0.035, + 0.04, + 0.055, + 1.0}}}}, + {"elements", QJsonArray{}}}; + titleLabel->setText("Graphite"); + statusLabel->setText("Open a .aui asset"); + rebuildTree(); + rebuildInspector(); + canvas->setDocument(document, {}); +} + +void GraphiteEditorPanel::openUI(const QString &path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + QMessageBox::warning(this, "Graphite", "The UI asset could not be opened."); + return; + } + QJsonParseError error; + const QJsonDocument parsed = QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError || !parsed.isObject()) { + QMessageBox::warning(this, "Graphite", "The UI asset is not valid JSON."); + return; + } + QJsonObject next = parsed.object(); + if (next.value("format").toString() != "atlas.graphite.ui" || + next.value("version").toInt() != 1) { + QMessageBox::warning(this, "Graphite", + "This is not a supported Graphite UI document."); + return; + } + if (!next.value("elements").isArray()) { + QJsonArray elements; + if (next.value("root").isObject()) { + elements.append(next.value("root")); + next.remove("root"); + } + next.insert("elements", elements); + } + bool repaired = false; + next = repairGraphiteColors(next, {}, repaired).toObject(); + uiPath = QFileInfo(path).absoluteFilePath(); + QJsonObject defaultFont = next.value("defaultFont").toObject(); + if (defaultFont.value("source").toString().trimmed().isEmpty()) { + const QString fontPath = ensureGraphiteDefaultFont( + QFileInfo(projectFile).absolutePath()); + if (!fontPath.isEmpty()) { + defaultFont.insert( + "source", + QDir(QFileInfo(uiPath).absolutePath()).relativeFilePath(fontPath)); + if (!defaultFont.contains("size")) + defaultFont.insert("size", 24); + next.insert("defaultFont", defaultFont); + repaired = true; + } + } + document = next; + undoStack->clear(); + titleLabel->setText(QFileInfo(uiPath).completeBaseName()); + statusLabel->setText("Ready"); + rebuildTree(); + rebuildInspector(); + canvas->setDocument(document, uiPath); + if (repaired) + saveUI(); +} + +void GraphiteEditorPanel::saveUI() { + if (uiPath.isEmpty()) + return; + QSaveFile file(uiPath); + const QByteArray contents = QJsonDocument(document).toJson(QJsonDocument::Indented); + if (!file.open(QIODevice::WriteOnly) || file.write(contents) != contents.size() || + !file.commit()) { + QMessageBox::warning(this, "Graphite", "The UI asset could not be saved."); + return; + } + statusLabel->setText("Saved"); +} + +void GraphiteEditorPanel::undo() { undoStack->undo(); } + +void GraphiteEditorPanel::redo() { undoStack->redo(); } + +QList GraphiteEditorPanel::selectedPath() const { + if (tree->currentItem() == nullptr) + return {}; + QList path; + const QStringList values = + tree->currentItem()->data(0, Qt::UserRole).toString().split('/'); + for (const QString &value : values) { + bool okay = false; + const int index = value.toInt(&okay); + if (okay) + path.append(index); + } + return path; +} + +QJsonObject GraphiteEditorPanel::elementAtPath(const QList &path) const { + if (path.isEmpty()) + return {}; + QJsonArray array = document.value("elements").toArray(); + QJsonObject element; + for (int depth = 0; depth < path.size(); ++depth) { + const int index = path.at(depth); + if (index < 0 || index >= array.size()) + return {}; + element = array.at(index).toObject(); + array = element.value("children").toArray(); + } + return element; +} + +void GraphiteEditorPanel::replaceElement(const QList &path, + const QJsonObject &element) { + setDocument(replaceAtPath(document, path, &element, false), true); +} + +void GraphiteEditorPanel::removeElement(const QList &path) { + setDocument(replaceAtPath(document, path, nullptr, true), true); +} + +void GraphiteEditorPanel::setDocument(const QJsonObject &next, bool recordUndo) { + if (next == document) + return; + const QList path = selectedPath(); + auto apply = [this, path](const QJsonObject &value) { + document = value; + rebuildTree(); + const QString key = pathKey(path); + const auto items = tree->findItems("*", Qt::MatchWildcard | + Qt::MatchRecursive); + for (QTreeWidgetItem *item : items) { + if (item->data(0, Qt::UserRole).toString() == key) { + tree->setCurrentItem(item); + break; + } + } + canvas->setDocument(document, uiPath); + canvas->setSelectedPath(path); + rebuildInspector(); + statusLabel->setText("Modified"); + }; + if (recordUndo) + undoStack->push(new GraphiteDocumentCommand(document, next, apply)); + else + apply(next); +} + +QTreeWidgetItem *GraphiteEditorPanel::appendTreeElement( + QTreeWidgetItem *parent, const QJsonObject &element, + const QList &path) { + auto *item = parent != nullptr ? new QTreeWidgetItem(parent) + : new QTreeWidgetItem(tree); + const QString type = element.value("type").toString(); + item->setText(0, element.value("name").toString(type)); + item->setData(0, Qt::UserRole, pathKey(path)); + item->setIcon(0, styling::icon( + type == "image" ? styling::Icon::Image + : type == "column" || type == "row" || type == "stack" + ? styling::Icon::Layout + : type == "button" || type == "checkbox" || + type == "textField" + ? styling::Icon::CursorClick + : styling::Icon::File, + "#8498A8")); + const QJsonArray children = element.value("children").toArray(); + for (int index = 0; index < children.size(); ++index) { + QList childPath = path; + childPath.append(index); + appendTreeElement(item, children.at(index).toObject(), childPath); + } + item->setExpanded(true); + return item; +} + +void GraphiteEditorPanel::rebuildTree() { + const QString selectedKey = tree->currentItem() != nullptr + ? tree->currentItem() + ->data(0, Qt::UserRole) + .toString() + : QString(); + tree->clear(); + const QJsonArray elements = document.value("elements").toArray(); + for (int index = 0; index < elements.size(); ++index) + appendTreeElement(nullptr, elements.at(index).toObject(), {index}); + if (!selectedKey.isEmpty()) { + const auto items = tree->findItems("*", Qt::MatchWildcard | + Qt::MatchRecursive); + for (QTreeWidgetItem *item : items) { + if (item->data(0, Qt::UserRole).toString() == selectedKey) { + tree->setCurrentItem(item); + break; + } + } + } +} + +QJsonObject GraphiteEditorPanel::defaultElement(const QString &type) { + const int number = nextElementNumber++; + QJsonObject element{{"id", QStringLiteral("%1_%2").arg(type).arg(number)}, + {"name", QStringLiteral("%1 %2") + .arg(type.left(1).toUpper() + type.mid(1)) + .arg(number)}, + {"type", type}, + {"position", QJsonArray{48 + number * 8, + 48 + number * 8}}, + {"components", QJsonArray{}}}; + if (type == "text") { + element.insert("content", "Text"); + element.insert("fontSize", 32); + element.insert("color", QJsonArray{1, 1, 1, 1}); + } else if (type == "image") { + element.insert("source", ""); + element.insert("size", QJsonArray{180, 120}); + } else if (type == "button") { + element.insert("label", "Button"); + element.insert("size", QJsonArray{180, 52}); + element.insert("enabled", true); + } else if (type == "checkbox") { + element.insert("label", "Checkbox"); + element.insert("size", QJsonArray{220, 44}); + element.insert("checked", false); + element.insert("enabled", true); + } else if (type == "textField") { + element.insert("text", ""); + element.insert("placeholder", "Text field"); + element.insert("size", QJsonArray{320, 48}); + } else { + element.insert("size", QJsonArray{360, 220}); + element.insert("padding", QJsonArray{12, 12}); + element.insert("spacing", 8); + element.insert("alignment", "start"); + element.insert("children", QJsonArray{}); + } + element.insert( + "style", + QJsonObject{{"normal", + QJsonObject{{"background", + QJsonArray{0.12, 0.13, 0.17, 0.96}}, + {"foreground", QJsonArray{1, 1, 1, 1}}, + {"borderWidth", 1}, + {"border", QJsonArray{1, 1, 1, 0.16}}, + {"cornerRadius", 8}}}}); + return element; +} + +void GraphiteEditorPanel::addElement(const QString &type) { + if (uiPath.isEmpty()) + return; + QJsonObject next = document; + QJsonObject element = defaultElement(type); + const QList path = selectedPath(); + QJsonObject parent = elementAtPath(path); + const QString parentType = parent.value("type").toString(); + if (!path.isEmpty() && + (parentType == "column" || parentType == "row" || + parentType == "stack")) { + QJsonArray children = parent.value("children").toArray(); + children.append(element); + parent.insert("children", children); + next = replaceAtPath(next, path, &parent, false); + } else { + QJsonArray elements = next.value("elements").toArray(); + elements.append(element); + next.insert("elements", elements); + } + setDocument(next, true); +} + +void GraphiteEditorPanel::deleteSelectedElement() { + const QList path = selectedPath(); + if (path.isEmpty()) + return; + removeElement(path); + tree->clearSelection(); +} + +void GraphiteEditorPanel::duplicateSelectedElement() { + const QList path = selectedPath(); + QJsonObject element = elementAtPath(path); + if (element.isEmpty()) + return; + element.insert("id", element.value("id").toString() + "_copy"); + element.insert("name", element.value("name").toString() + " Copy"); + QJsonObject next = document; + if (path.size() == 1) { + QJsonArray elements = next.value("elements").toArray(); + elements.insert(path.first() + 1, element); + next.insert("elements", elements); + } else { + QList parentPath = path; + const int index = parentPath.takeLast(); + QJsonObject parent = elementAtPath(parentPath); + QJsonArray children = parent.value("children").toArray(); + children.insert(index + 1, element); + parent.insert("children", children); + next = replaceAtPath(next, parentPath, &parent, false); + } + setDocument(next, true); +} + +void GraphiteEditorPanel::rebuildInspector() { + while (QLayoutItem *item = inspectorLayout->takeAt(0)) { + if (item->widget() != nullptr) + item->widget()->deleteLater(); + delete item; + } + const QList path = selectedPath(); + QJsonObject element = elementAtPath(path); + if (element.isEmpty()) { + auto *documentGroup = new QGroupBox("Document", inspectorBody); + auto *documentForm = new QFormLayout(documentGroup); + auto *name = + new QLineEdit(document.value("name").toString(), documentGroup); + QJsonObject canvasData = document.value("canvas").toObject(); + auto *width = new QDoubleSpinBox(documentGroup); + auto *height = new QDoubleSpinBox(documentGroup); + width->setRange(1, 100000); + height->setRange(1, 100000); + width->setDecimals(0); + height->setDecimals(0); + width->setValue(canvasData.value("width").toDouble(1280)); + height->setValue(canvasData.value("height").toDouble(720)); + width->setKeyboardTracking(false); + height->setKeyboardTracking(false); + documentForm->addRow("Name", name); + documentForm->addRow("Canvas Width", width); + documentForm->addRow("Canvas Height", height); + inspectorLayout->addWidget(documentGroup); + + auto *fontGroup = new QGroupBox("Default Font", inspectorBody); + auto *fontForm = new QFormLayout(fontGroup); + QJsonObject fontData = document.value("defaultFont").toObject(); + auto *fontPath = + new QLineEdit(fontData.value("source").toString(), fontGroup); + auto *chooseFont = new QPushButton("Choose…", fontGroup); + auto *fontRow = new QWidget(fontGroup); + auto *fontRowLayout = new QHBoxLayout(fontRow); + fontRowLayout->setContentsMargins(0, 0, 0, 0); + fontRowLayout->addWidget(fontPath, 1); + fontRowLayout->addWidget(chooseFont); + auto *fontSize = new QDoubleSpinBox(fontGroup); + fontSize->setRange(1, 512); + fontSize->setDecimals(0); + fontSize->setValue(fontData.value("size").toDouble(24)); + fontSize->setKeyboardTracking(false); + fontForm->addRow("Source", fontRow); + fontForm->addRow("Size", fontSize); + inspectorLayout->addWidget(fontGroup); + auto updateDocument = [this](const std::function &edit) { + QJsonObject next = document; + edit(next); + setDocument(next, true); + }; + connect(name, &QLineEdit::editingFinished, this, + [name, updateDocument] { + updateDocument([name](QJsonObject &next) { + next.insert("name", name->text().trimmed()); + }); + }); + auto updateCanvas = [width, height, updateDocument] { + updateDocument([width, height](QJsonObject &next) { + QJsonObject canvas = next.value("canvas").toObject(); + canvas.insert("width", width->value()); + canvas.insert("height", height->value()); + next.insert("canvas", canvas); + }); + }; + connect(width, &QDoubleSpinBox::editingFinished, this, updateCanvas); + connect(height, &QDoubleSpinBox::editingFinished, this, updateCanvas); + auto updateFont = [fontPath, fontSize, updateDocument] { + updateDocument([fontPath, fontSize](QJsonObject &next) { + next.insert("defaultFont", + QJsonObject{{"source", fontPath->text()}, + {"size", fontSize->value()}}); + }); + }; + connect(fontPath, &QLineEdit::editingFinished, this, updateFont); + connect(fontSize, &QDoubleSpinBox::editingFinished, this, updateFont); + connect(chooseFont, &QPushButton::clicked, this, + [this, fontPath, updateFont] { + const QString selected = QFileDialog::getOpenFileName( + this, "Choose Font", QFileInfo(uiPath).absolutePath(), + "Fonts (*.ttf *.otf)"); + if (selected.isEmpty()) + return; + fontPath->setText( + QDir(QFileInfo(uiPath).absolutePath()) + .relativeFilePath(selected)); + updateFont(); + }); + inspectorLayout->addStretch(); + return; + } + auto update = [this, path](const QString &key, const QJsonValue &value) { + QJsonObject changed = elementAtPath(path); + if (changed.isEmpty()) + return; + changed.insert(key, value); + replaceElement(path, changed); + }; + auto *identity = new QGroupBox("Element", inspectorBody); + auto *identityForm = new QFormLayout(identity); + auto *name = new QLineEdit(element.value("name").toString(), identity); + auto *id = new QLineEdit(element.value("id").toString(), identity); + id->setPlaceholderText("Stable script id"); + auto *type = new QLabel(element.value("type").toString(), identity); + identityForm->addRow("Name", name); + identityForm->addRow("ID", id); + identityForm->addRow("Type", type); + inspectorLayout->addWidget(identity); + connect(name, &QLineEdit::editingFinished, this, + [name, update] { update("name", name->text().trimmed()); }); + connect(id, &QLineEdit::editingFinished, this, + [id, update] { update("id", id->text().trimmed()); }); + + auto spin = [](double value, double minimum, double maximum, + QWidget *parent) { + auto *field = new QDoubleSpinBox(parent); + field->setRange(minimum, maximum); + field->setDecimals(1); + field->setValue(value); + field->setKeyboardTracking(false); + return field; + }; + auto *geometry = new QGroupBox("Geometry", inspectorBody); + auto *geometryForm = new QFormLayout(geometry); + const QPointF position = jsonPoint(element.value("position")); + const QSizeF size = jsonSize(element); + auto *x = spin(position.x(), -100000, 100000, geometry); + auto *y = spin(position.y(), -100000, 100000, geometry); + auto *width = spin(size.width(), 1, 100000, geometry); + auto *height = spin(size.height(), 1, 100000, geometry); + geometryForm->addRow("X", x); + geometryForm->addRow("Y", y); + geometryForm->addRow("Width", width); + geometryForm->addRow("Height", height); + inspectorLayout->addWidget(geometry); + connect(x, &QDoubleSpinBox::editingFinished, this, [x, y, update] { + update("position", QJsonArray{x->value(), y->value()}); + }); + connect(y, &QDoubleSpinBox::editingFinished, this, [x, y, update] { + update("position", QJsonArray{x->value(), y->value()}); + }); + connect(width, &QDoubleSpinBox::editingFinished, this, + [width, height, update] { + update("size", QJsonArray{width->value(), height->value()}); + }); + connect(height, &QDoubleSpinBox::editingFinished, this, + [width, height, update] { + update("size", QJsonArray{width->value(), height->value()}); + }); + + const QString elementType = element.value("type").toString(); + if (elementType == "text" || elementType == "button" || + elementType == "checkbox" || elementType == "textField" || + elementType == "image") { + auto *contentGroup = new QGroupBox("Content", inspectorBody); + auto *contentForm = new QFormLayout(contentGroup); + if (elementType == "text" || elementType == "button" || + elementType == "checkbox") { + const QString key = elementType == "text" ? "content" : "label"; + auto *content = new QLineEdit(element.value(key).toString(), + contentGroup); + contentForm->addRow(elementType == "text" ? "Text" : "Label", + content); + connect(content, &QLineEdit::editingFinished, this, + [content, key, update] { update(key, content->text()); }); + } else if (elementType == "textField") { + auto *content = + new QLineEdit(element.value("text").toString(), contentGroup); + auto *placeholder = new QLineEdit( + element.value("placeholder").toString(), contentGroup); + contentForm->addRow("Text", content); + contentForm->addRow("Placeholder", placeholder); + connect(content, &QLineEdit::editingFinished, this, + [content, update] { update("text", content->text()); }); + connect(placeholder, &QLineEdit::editingFinished, this, + [placeholder, update] { + update("placeholder", placeholder->text()); + }); + } else { + auto *source = new QLineEdit(element.value("source").toString(), + contentGroup); + auto *choose = new QPushButton("Choose…", contentGroup); + auto *row = new QWidget(contentGroup); + auto *rowLayout = new QHBoxLayout(row); + rowLayout->setContentsMargins(0, 0, 0, 0); + rowLayout->addWidget(source, 1); + rowLayout->addWidget(choose); + contentForm->addRow("Image", row); + connect(source, &QLineEdit::editingFinished, this, + [source, update] { update("source", source->text()); }); + connect(choose, &QPushButton::clicked, this, + [this, source, update] { + const QString selected = QFileDialog::getOpenFileName( + this, "Choose Image", QFileInfo(uiPath).absolutePath(), + "Images (*.png *.jpg *.jpeg *.bmp *.tga *.hdr *.exr)"); + if (selected.isEmpty()) + return; + const QString relative = + QDir(QFileInfo(uiPath).absolutePath()) + .relativeFilePath(selected); + source->setText(relative); + update("source", relative); + }); + } + inspectorLayout->addWidget(contentGroup); + } + + if (elementType == "column" || elementType == "row" || + elementType == "stack") { + auto *layoutGroup = new QGroupBox("Layout", inspectorBody); + auto *layoutForm = new QFormLayout(layoutGroup); + auto *spacing = + spin(element.value("spacing").toDouble(8), 0, 1000, layoutGroup); + auto *alignment = new QComboBox(layoutGroup); + alignment->addItems({"start", "center", "end"}); + alignment->setCurrentText(element.value("alignment").toString("start")); + layoutForm->addRow("Spacing", spacing); + layoutForm->addRow("Alignment", alignment); + inspectorLayout->addWidget(layoutGroup); + connect(spacing, &QDoubleSpinBox::editingFinished, this, + [spacing, update] { update("spacing", spacing->value()); }); + connect(alignment, &QComboBox::currentTextChanged, this, + [update](const QString &value) { update("alignment", value); }); + } + + auto *appearance = new QGroupBox("Appearance", inspectorBody); + auto *appearanceForm = new QFormLayout(appearance); + QJsonObject style = element.value("style").toObject(); + auto *state = new QComboBox(appearance); + state->addItems( + {"normal", "hovered", "pressed", "focused", "disabled", "checked"}); + state->setCurrentText(styleVariant); + QJsonObject normal = style.value(styleVariant).toObject(); + const QJsonObject fallbackNormal = style.value("normal").toObject(); + const QColor background = + jsonColor(normal.value("background"), + jsonColor(fallbackNormal.value("background"), + QColor("#303440"))); + const QColor foreground = + jsonColor(normal.value("foreground"), + jsonColor(fallbackNormal.value("foreground"), + QColor("#F5F6F8"))); + auto *backgroundButton = new QPushButton(appearance); + backgroundButton->setIcon(styling::colorSwatch(background)); + backgroundButton->setText(background.name(QColor::HexArgb)); + auto *foregroundButton = new QPushButton(appearance); + foregroundButton->setIcon(styling::colorSwatch(foreground)); + foregroundButton->setText(foreground.name(QColor::HexArgb)); + auto *radius = + spin(normal.value("cornerRadius") + .toDouble(fallbackNormal.value("cornerRadius").toDouble(8)), + 0, 1000, appearance); + appearanceForm->addRow("State", state); + appearanceForm->addRow("Background", backgroundButton); + appearanceForm->addRow("Foreground", foregroundButton); + appearanceForm->addRow("Corner Radius", radius); + inspectorLayout->addWidget(appearance); + auto updateStyle = [this, path](const QString &key, const QJsonValue &value) { + QJsonObject changed = elementAtPath(path); + QJsonObject style = changed.value("style").toObject(); + QJsonObject normal = style.value(styleVariant).toObject(); + normal.insert(key, value); + style.insert(styleVariant, normal); + changed.insert("style", style); + replaceElement(path, changed); + }; + connect(backgroundButton, &QPushButton::clicked, this, + [this, background, updateStyle] { + const QColor selected = + chooseColor(this, background, "Background"); + if (selected.isValid()) + updateStyle("background", colorJson(selected)); + }); + connect(foregroundButton, &QPushButton::clicked, this, + [this, foreground, updateStyle] { + const QColor selected = + chooseColor(this, foreground, "Foreground"); + if (selected.isValid()) + updateStyle("foreground", colorJson(selected)); + }); + connect(radius, &QDoubleSpinBox::editingFinished, this, + [radius, updateStyle] { + updateStyle("cornerRadius", radius->value()); + }); + connect(state, &QComboBox::currentTextChanged, this, + [this](const QString &value) { + styleVariant = value; + rebuildInspector(); + }); + + auto *components = new QGroupBox("Components", inspectorBody); + auto *componentsLayout = new QVBoxLayout(components); + auto *componentList = new QListWidget(components); + const QJsonArray componentData = element.value("components").toArray(); + for (const QJsonValue &value : componentData) { + const QJsonObject component = value.toObject(); + auto *item = new QListWidgetItem( + styling::icon(styling::Icon::FileCode, "#7E929C"), + component.value("className") + .toString(component.value("name").toString("Script")), + componentList); + item->setToolTip(component.value("source").toString()); + } + auto *componentButtons = new QHBoxLayout(); + auto *addScript = new QPushButton("Add Script…", components); + auto *removeScript = new QPushButton("Remove", components); + componentButtons->addWidget(addScript); + componentButtons->addWidget(removeScript); + componentsLayout->addWidget(componentList); + auto *componentForm = new QFormLayout(); + auto *componentClass = new QLineEdit(components); + auto *componentSource = new QLineEdit(components); + componentSource->setReadOnly(true); + auto *componentVariables = new QPlainTextEdit(components); + componentVariables->setPlaceholderText("{}"); + componentVariables->setMaximumHeight(92); + auto *applyComponent = new QPushButton("Apply Component", components); + componentForm->addRow("Class", componentClass); + componentForm->addRow("Source", componentSource); + componentForm->addRow("Variables", componentVariables); + componentsLayout->addLayout(componentForm); + componentsLayout->addWidget(applyComponent); + componentsLayout->addLayout(componentButtons); + inspectorLayout->addWidget(components); + connect(addScript, &QPushButton::clicked, this, + &GraphiteEditorPanel::addScriptComponent); + connect(removeScript, &QPushButton::clicked, this, + [this, componentList] { + componentList->setProperty("componentIndex", + componentList->currentRow()); + removeScriptComponent(); + }); + auto displayComponent = [componentData, componentClass, componentSource, + componentVariables](int index) { + const bool valid = index >= 0 && index < componentData.size(); + componentClass->setEnabled(valid); + componentSource->setEnabled(valid); + componentVariables->setEnabled(valid); + if (!valid) { + componentClass->clear(); + componentSource->clear(); + componentVariables->clear(); + return; + } + const QJsonObject component = componentData.at(index).toObject(); + componentClass->setText(component.value("className").toString()); + componentSource->setText(component.value("source").toString()); + const QJsonObject variables = component.value("variables").toObject(); + componentVariables->setPlainText( + QString::fromUtf8(QJsonDocument(variables).toJson( + QJsonDocument::Indented))); + }; + connect(componentList, &QListWidget::currentRowChanged, this, + displayComponent); + connect(applyComponent, &QPushButton::clicked, this, + [this, path, componentList, componentClass, componentVariables] { + const int index = componentList->currentRow(); + QJsonObject changed = elementAtPath(path); + QJsonArray entries = changed.value("components").toArray(); + if (index < 0 || index >= entries.size()) + return; + QJsonParseError error; + const QJsonDocument variables = QJsonDocument::fromJson( + componentVariables->toPlainText().toUtf8(), &error); + if (error.error != QJsonParseError::NoError || + !variables.isObject()) { + QMessageBox::warning(this, "Graphite", + "Component variables must be a JSON object."); + return; + } + QJsonObject component = entries.at(index).toObject(); + component.insert("className", + componentClass->text().trimmed()); + component.insert("variables", variables.object()); + entries[index] = component; + changed.insert("components", entries); + replaceElement(path, changed); + }); + componentList->setProperty("graphiteComponentList", true); + if (!componentData.isEmpty()) + componentList->setCurrentRow(0); + else + displayComponent(-1); + inspectorLayout->addStretch(); +} + +void GraphiteEditorPanel::refreshDocument(bool) { + rebuildTree(); + rebuildInspector(); + canvas->setDocument(document, uiPath); +} + +void GraphiteEditorPanel::addScriptComponent() { + const QList path = selectedPath(); + QJsonObject element = elementAtPath(path); + if (element.isEmpty()) + return; + const QString root = QFileInfo(projectFile).absolutePath(); + const QString source = QFileDialog::getOpenFileName( + this, "Attach TypeScript Component", root, "TypeScript (*.ts)"); + if (source.isEmpty()) + return; + QString className = QFileInfo(source).completeBaseName(); + className.remove(QRegularExpression("[^A-Za-z0-9_$]")); + if (className.isEmpty()) + className = "UIComponent"; + if (className.front().isDigit()) + className.prepend("Component"); + bool accepted = false; + className = QInputDialog::getText(this, "Script Component", "Class name", + QLineEdit::Normal, className, &accepted) + .trimmed(); + if (!accepted || className.isEmpty()) + return; + QJsonArray components = element.value("components").toArray(); + components.append(QJsonObject{ + {"type", "script"}, + {"source", QDir(QFileInfo(uiPath).absolutePath()).relativeFilePath(source)}, + {"className", className}, + {"variables", QJsonObject{}}}); + element.insert("components", components); + replaceElement(path, element); +} + +void GraphiteEditorPanel::removeScriptComponent() { + const QList path = selectedPath(); + QJsonObject element = elementAtPath(path); + if (element.isEmpty()) + return; + auto lists = inspectorBody->findChildren(); + int index = -1; + for (QListWidget *list : lists) { + if (list->property("graphiteComponentList").toBool()) { + index = list->currentRow(); + break; + } + } + QJsonArray components = element.value("components").toArray(); + if (index < 0 || index >= components.size()) + return; + components.removeAt(index); + element.insert("components", components); + replaceElement(path, element); +} + +void GraphiteEditorPanel::attachToScene(bool preview) { + if (uiPath.isEmpty() || viewport == nullptr) + return; + saveUI(); + const QString scenePath = viewport->currentRuntimeScene(); + if (scenePath.isEmpty()) { + QMessageBox::information(this, "Graphite", + "Open a scene before attaching this UI."); + return; + } + QFile input(scenePath); + if (!input.open(QIODevice::ReadOnly)) { + QMessageBox::warning(this, "Graphite", "The scene could not be opened."); + return; + } + QJsonParseError error; + QJsonDocument parsed = QJsonDocument::fromJson(input.readAll(), &error); + if (error.error != QJsonParseError::NoError || !parsed.isObject()) { + QMessageBox::warning(this, "Graphite", "The scene is not valid JSON."); + return; + } + QJsonObject scene = parsed.object(); + QJsonArray interfaces = scene.value("ui").toArray(); + const QString relative = + QDir(QFileInfo(scenePath).absolutePath()).relativeFilePath(uiPath); + bool found = false; + for (const QJsonValue &value : interfaces) { + if ((value.isString() && value.toString() == relative) || + (value.isObject() && + value.toObject().value("source").toString() == relative)) { + found = true; + break; + } + } + if (!found) + interfaces.append(relative); + scene.insert("ui", interfaces); + QSaveFile output(scenePath); + const QByteArray contents = + QJsonDocument(scene).toJson(QJsonDocument::Indented); + if (!output.open(QIODevice::WriteOnly) || + output.write(contents) != contents.size() || !output.commit()) { + QMessageBox::warning(this, "Graphite", "The scene could not be updated."); + return; + } + statusLabel->setText("Attached to scene"); + if (preview) { + auto connection = std::make_shared(); + *connection = connect( + viewport, &ViewportPanel::runtimeAvailabilityChanged, this, + [this, connection](bool available) { + if (!available) + return; + disconnect(*connection); + if (viewport != nullptr) + viewport->setCameraFocused(true); + }); + emit previewRequested(); + } + viewport->reloadRuntime(); +} diff --git a/editor/views/editor/hierarchy.cpp b/editor/views/editor/hierarchy.cpp index 3c30f205..4d8b9538 100644 --- a/editor/views/editor/hierarchy.cpp +++ b/editor/views/editor/hierarchy.cpp @@ -40,6 +40,7 @@ namespace { constexpr int ObjectIdRole = Qt::UserRole + 1; constexpr int ObjectTypeRole = Qt::UserRole + 2; +constexpr int AssetPathRole = Qt::UserRole + 3; QIcon hierarchyIcon(QWidget *, const QString &type) { const QString normalized = type.toLower(); @@ -51,6 +52,8 @@ QIcon hierarchyIcon(QWidget *, const QString &type) { return styling::icon(styling::Icon::Camera, "#9E897D"); if (normalized == "environment") return styling::icon(styling::Icon::Globe, "#7E929C"); + if (normalized == "graphite" || normalized == "graphiteasset") + return styling::icon(styling::Icon::Palette, "#849589"); if (normalized.contains("light") || normalized == "sun") return styling::icon(styling::Icon::Lightbulb, "#A1957D"); if (normalized == "terrain" || normalized == "landscape") @@ -275,11 +278,12 @@ void HierarchyPanel::applySceneSnapshot(const QString &snapshot) { const QJsonObject scene = document.object(); const QString sceneName = scene.value("name").toString("Scene"); const QJsonArray objects = scene.value("objects").toArray(); + const QJsonArray interfaces = scene.value("ui").toArray(); const int selectedId = scene.value("selectedId").toInt(-1); - const QString signature = sceneSignature(sceneName, objects); + const QString signature = sceneSignature(sceneName, objects, interfaces); if (signature != lastStructureSignature) { - rebuildScene(sceneName, objects, selectedId); + rebuildScene(sceneName, objects, interfaces, selectedId); lastStructureSignature = signature; return; } @@ -307,7 +311,8 @@ void HierarchyPanel::applySceneSnapshot(const QString &snapshot) { } void HierarchyPanel::rebuildScene(const QString &sceneName, - const QJsonArray &objects, int selectedId) { + const QJsonArray &objects, + const QJsonArray &interfaces, int selectedId) { applyingSnapshot = true; model->clear(); itemsById.clear(); @@ -336,6 +341,43 @@ void HierarchyPanel::rebuildScene(const QString &sceneName, environment->setEditable(false); specialItems.insert("environment", environment); root->appendRow(environment); + + auto *graphite = new QStandardItem( + hierarchyIcon(this, "graphite"), "Graphite Overlay"); + graphite->setData(-1, ObjectIdRole); + graphite->setData("graphite", ObjectTypeRole); + graphite->setToolTip("Scene UI overlays"); + graphite->setEditable(false); + specialItems.insert("graphite", graphite); + for (const QJsonValue &value : interfaces) { + QString source; + bool enabled = true; + if (value.isString()) { + source = value.toString(); + } else if (value.isObject()) { + const QJsonObject entry = value.toObject(); + source = entry.value("source").toString(); + enabled = entry.value("enabled").toBool(true); + } + if (source.isEmpty()) + continue; + QString label = QFileInfo(source).completeBaseName(); + if (label.isEmpty()) + label = source; + if (!enabled) + label += " (Disabled)"; + auto *asset = new QStandardItem( + hierarchyIcon(this, "graphiteAsset"), label); + asset->setData(-1, ObjectIdRole); + asset->setData("graphiteAsset", ObjectTypeRole); + asset->setData(source, AssetPathRole); + asset->setToolTip(source); + asset->setEditable(false); + const QString key = "graphite:" + source; + specialItems.insert(key, asset); + graphite->appendRow(asset); + } + root->appendRow(graphite); model->appendRow(root); treeView->expandAll(); @@ -524,6 +566,13 @@ void HierarchyPanel::focusSelectedObject() { selectedSpecialType = type; viewport->selectRuntimeObject(-1, false); emit environmentActivated(); + } else if (type == "graphite" || type == "graphiteAsset") { + const QString source = + treeView->currentIndex().data(AssetPathRole).toString(); + selectedSpecialType = + source.isEmpty() ? "graphite" : "graphite:" + source; + viewport->selectRuntimeObject(-1, false); + emit graphiteActivated(source); } } @@ -576,6 +625,9 @@ void HierarchyPanel::showCreationPopup() { } QString HierarchyPanel::sceneSignature(const QString &sceneName, - const QJsonArray &objects) const { - return sceneName + ':' + objectSignature(objects); + const QJsonArray &objects, + const QJsonArray &interfaces) const { + return sceneName + ':' + objectSignature(objects) + ':' + + QString::fromUtf8( + QJsonDocument(interfaces).toJson(QJsonDocument::Compact)); } diff --git a/editor/views/general/contentBrowser.cpp b/editor/views/general/contentBrowser.cpp index b0dcee81..4cf6f7d7 100644 --- a/editor/views/general/contentBrowser.cpp +++ b/editor/views/general/contentBrowser.cpp @@ -23,6 +23,8 @@ #include #include #include +#include +#include #include #include #include @@ -69,6 +71,23 @@ const QByteArray EmptyScene = R"({ } )"; +const QByteArray EmptyGraphiteUI = R"({ + "format": "atlas.graphite.ui", + "version": 1, + "name": "New UI", + "canvas": { + "width": 1280, + "height": 720, + "background": [0.035, 0.04, 0.055, 1.0] + }, + "defaultFont": { + "source": "", + "size": 24 + }, + "elements": [] +} +)"; + bool writeNewFile(const QString &path, const QByteArray &contents) { QSaveFile file(path); return file.open(QIODevice::WriteOnly) && @@ -136,6 +155,8 @@ class AtlasFileIconProvider : public QFileIconProvider { return styling::icon(styling::Icon::CubeFocus, "#8498A8"); if (suffix == "amat" || suffix == "material") return styling::icon(styling::Icon::Material, "#9E897D"); + if (suffix == "aui") + return styling::icon(styling::Icon::Palette, "#849589"); if (suffix == "ts" || suffix == "js" || suffix == "cpp" || suffix == "h" || suffix == "json") return styling::icon(styling::Icon::FileCode, "#7E929C"); @@ -302,6 +323,9 @@ ContentBrowserPanel::ContentBrowserPanel(const QString &projectFile, createMenu->addAction(styling::icon(styling::Icon::Material, "#9E897D"), "Material", this, &ContentBrowserPanel::createMaterial); + createMenu->addAction(styling::icon(styling::Icon::Palette, "#849589"), + "Graphite UI", this, + &ContentBrowserPanel::createUI); createMenu->addAction(styling::icon(styling::Icon::FileCode, "#7E929C"), "TypeScript Script", this, &ContentBrowserPanel::createScript); @@ -468,6 +492,10 @@ void ContentBrowserPanel::openIndex(const QModelIndex &index) { emit sceneActivated(info.absoluteFilePath()); return; } + if (suffix == "aui") { + emit uiActivated(info.absoluteFilePath()); + return; + } QDesktopServices::openUrl(QUrl::fromLocalFile(info.absoluteFilePath())); } @@ -599,6 +627,24 @@ void ContentBrowserPanel::createMaterial() { } } +void ContentBrowserPanel::createUI() { + const QString path = requestFilePath(this, currentPath, "New Graphite UI", + "UI name", "New UI", "aui"); + if (path.isEmpty()) + return; + QJsonObject root = + QJsonDocument::fromJson(EmptyGraphiteUI).object(); + root.insert("name", QFileInfo(path).completeBaseName()); + const QByteArray contents = + QJsonDocument(root).toJson(QJsonDocument::Indented); + if (writeNewFile(path, contents)) { + const QModelIndex index = + filterModel->mapFromSource(model->index(path)); + gridView->setCurrentIndex(index); + emit uiActivated(path); + } +} + void ContentBrowserPanel::renameSelection() { const QString path = selectedPath(); if (path.isEmpty()) { diff --git a/graphite/FORMAT.md b/graphite/FORMAT.md new file mode 100644 index 00000000..7b50eb91 --- /dev/null +++ b/graphite/FORMAT.md @@ -0,0 +1,119 @@ +# Atlas Graphite UI format + +Graphite UI documents use the `.aui` extension and JSON encoding. Version 1 documents have this shape: + +```json +{ + "format": "atlas.graphite.ui", + "version": 1, + "name": "Main HUD", + "canvas": { + "width": 1280, + "height": 720, + "background": [0.035, 0.04, 0.055, 1.0] + }, + "defaultFont": { + "source": "fonts/Inter-Regular.ttf", + "size": 24 + }, + "elements": [ + { + "id": "title", + "name": "Title", + "type": "text", + "position": [48, 48], + "content": "Atlas", + "fontSize": 42, + "color": [1, 1, 1, 1], + "components": [] + } + ] +} +``` + +A scene enables one or more UI documents through its top-level `ui` array. Paths are relative to the scene file. + +```json +{ + "name": "Main", + "objects": [], + "ui": [ + "ui/Main HUD.aui", + { "source": "ui/Pause Menu.aui", "enabled": true } + ] +} +``` + +In a running game, Graphite renders after the scene. In the editor scene workspace it renders only while looking through the scene camera, keeping world editing unobstructed. + +## Document fields + +- `format`: `atlas.graphite.ui`. +- `version`: currently `1`. +- `name`: display name used by the editor. +- `canvas`: reference width, height, and preview background. +- `defaultFont`: font resource inherited by text-capable elements. `source` is relative to the `.aui` file. +- `elements`: top-level element array. `root` can be used instead when a single layout owns the document. + +## Elements + +Every element accepts `id`, `name`, `type`, `position`, `style`, and `components`. Layout elements also accept `children`. + +Supported types are: + +- `text`: `content`, `font`, `fontSize`, `color`. +- `image`: `source`, `size`, `tint`. +- `button`: `label`, `size`, `padding`, `font`, `fontSize`, `enabled`. +- `checkbox`: `label`, `checked`, `enabled`, `boxSize`, `spacing`, `padding`, `font`, `fontSize`. +- `textField`: `text`, `placeholder`, `size`, `maximumWidth`, `padding`, `font`, `fontSize`. +- `column` and `row`: `children`, `size`, `padding`, `spacing`, `alignment`, `anchor`. +- `stack`: `children`, `size`, `padding`, `horizontalAlignment`, `verticalAlignment`, `anchor`. + +Colors use normalized RGBA arrays such as `[0.1, 0.2, 0.3, 1.0]`. RGB and 0–255 arrays are accepted as well. Positions and sizes use two-number arrays. + +## Styles + +The `style` object can contain `normal`, `hovered`, `pressed`, `focused`, `disabled`, and `checked` variants. A variant supports `padding`, `cornerRadius`, `background`, `borderWidth`, `border`, `foreground`, `tint`, and `fontSize`. + +```json +{ + "style": { + "normal": { + "background": [0.12, 0.13, 0.17, 0.96], + "foreground": [1, 1, 1, 1], + "borderWidth": 1, + "border": [1, 1, 1, 0.16], + "cornerRadius": 10 + }, + "hovered": { + "background": [0.18, 0.2, 0.26, 1] + } + } +} +``` + +## Script components + +Each UI element uses the same component array as a scene object. Script components therefore participate in the normal Atlas script lifecycle, variable serialization, `init`, `update`, and `atAttach` callbacks. + +```json +{ + "type": "button", + "name": "Continue Button", + "label": "Continue", + "position": [80, 420], + "size": [240, 56], + "components": [ + { + "type": "script", + "source": "scripts/ContinueButton.ts", + "className": "ContinueButton", + "variables": { + "scene": "Level One" + } + } + ] +} +``` + +The editor stores script paths relative to the `.aui` document and uses the shared `atlas script compile` output when the project runs or previews. diff --git a/graphite/README.md b/graphite/README.md index 9f179620..9f7adfa1 100644 --- a/graphite/README.md +++ b/graphite/README.md @@ -2,7 +2,9 @@ Graphite UI is the UI library for the Atlas Engine. It helps game developers create user interfaces for their games. It provides a set of tools and components that can be used to create complex and interactive user interfaces. +Authored interfaces are stored as `.aui` documents and can be attached to scenes. See [FORMAT.md](FORMAT.md) for the versioned file contract, supported elements, styles, and script components. + ## Features - A wide range of UI components, including buttons, text fields, and more. - Support for custom themes and styles. -- Easy integration with the Atlas Engine. \ No newline at end of file +- Easy integration with the Atlas Engine. diff --git a/graphite/layout.cpp b/graphite/layout.cpp index 15dc5a0c..dd180e13 100644 --- a/graphite/layout.cpp +++ b/graphite/layout.cpp @@ -83,6 +83,18 @@ void setChildTopLeft(UIObject *child, const Position2d &topLeft, } // namespace +void Column::initialize() { + for (auto &component : components) { + component->init(); + } + graphite::initializeBoxRenderer(boxRenderer, id); + for (auto *child : children) { + if (child != nullptr) { + child->initialize(); + } + } +} + void Column::addChild(UIObject *child) { children.push_back(child); recalculatePositions(); @@ -114,6 +126,13 @@ void Column::setProjectionMatrix(const glm::mat4 &projection) { void Column::render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) { + if (boxRenderer.shader.shader == nullptr || boxRenderer.vao == nullptr || + boxRenderer.vertexBuffer == nullptr) { + initialize(); + } + for (auto &component : components) { + component->update(dt); + } recalculatePositions(); const graphite::UIResolvedStyle style = graphite::resolveStyle( makeLayoutStyle(padding), &graphite::Theme::current().column, @@ -180,6 +199,18 @@ void Row::addChild(UIObject *child) { recalculatePositions(); } +void Row::initialize() { + for (auto &component : components) { + component->init(); + } + graphite::initializeBoxRenderer(boxRenderer, id); + for (auto *child : children) { + if (child != nullptr) { + child->initialize(); + } + } +} + void Row::setChildren(const std::vector &newChildren) { children = newChildren; recalculatePositions(); @@ -205,6 +236,13 @@ void Row::setProjectionMatrix(const glm::mat4 &projection) { void Row::render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) { + if (boxRenderer.shader.shader == nullptr || boxRenderer.vao == nullptr || + boxRenderer.vertexBuffer == nullptr) { + initialize(); + } + for (auto &component : components) { + component->update(dt); + } recalculatePositions(); const graphite::UIResolvedStyle style = graphite::resolveStyle( makeLayoutStyle(padding), &graphite::Theme::current().row, @@ -271,6 +309,18 @@ void Stack::addChild(UIObject *child) { recalculatePositions(); } +void Stack::initialize() { + for (auto &component : components) { + component->init(); + } + graphite::initializeBoxRenderer(boxRenderer, id); + for (auto *child : children) { + if (child != nullptr) { + child->initialize(); + } + } +} + void Stack::setChildren(const std::vector &newChildren) { children = newChildren; recalculatePositions(); @@ -296,6 +346,13 @@ void Stack::setProjectionMatrix(const glm::mat4 &projection) { void Stack::render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) { + if (boxRenderer.shader.shader == nullptr || boxRenderer.vao == nullptr || + boxRenderer.vertexBuffer == nullptr) { + initialize(); + } + for (auto &component : components) { + component->update(dt); + } recalculatePositions(); const graphite::UIResolvedStyle style = graphite::resolveStyle( makeLayoutStyle(padding), &graphite::Theme::current().stack, diff --git a/include/atlas/runtime/context.h b/include/atlas/runtime/context.h index 2c99ad23..ec43f43c 100644 --- a/include/atlas/runtime/context.h +++ b/include/atlas/runtime/context.h @@ -110,6 +110,7 @@ class Context { json editorTargetData = json::array(); json editorEnvironmentData = json::object(); json editorPropertySyncs = json::array(); + json editorUIData = json::array(); bool applyingPropertySyncs = false; std::function modelImportProgress; std::vector> deletedObjectReferences; diff --git a/include/editor/views/editorWindow.h b/include/editor/views/editorWindow.h index 3a6b8235..939e6a35 100644 --- a/include/editor/views/editorWindow.h +++ b/include/editor/views/editorWindow.h @@ -27,6 +27,7 @@ class HierarchyPanel; class ContentBrowserPanel; class ViewportTools; class MaterialEditorPanel; +class GraphiteEditorPanel; class PostProcessingPanel; class QMenu; class QButtonGroup; @@ -77,6 +78,7 @@ class EditorWindow : public QMainWindow { ViewportPanel* viewportPanel = nullptr; InspectorPanel* inspectorPanel = nullptr; MaterialEditorPanel* materialEditorPanel = nullptr; + GraphiteEditorPanel* graphiteEditorPanel = nullptr; PostProcessingPanel* postProcessingPanel = nullptr; HierarchyPanel* hierarchyPanel = nullptr; ContentBrowserPanel* contentBrowser = nullptr; diff --git a/include/editor/views/fileExplorer.h b/include/editor/views/fileExplorer.h index 3d8e972e..0f972854 100644 --- a/include/editor/views/fileExplorer.h +++ b/include/editor/views/fileExplorer.h @@ -39,12 +39,14 @@ class ContentBrowserPanel : public QWidget { void pasteSelection(); void refreshAssets(); void selectAllAssets(); + void createUI(); QString selectedPath() const; signals: void selectionChanged(const QString &path); void assetActivated(const QString &path); void sceneActivated(const QString &path); + void uiActivated(const QString &path); private: void navigateTo(const QString &path, bool recordHistory = true); diff --git a/include/editor/views/graphiteEditor.h b/include/editor/views/graphiteEditor.h new file mode 100644 index 00000000..9f33f964 --- /dev/null +++ b/include/editor/views/graphiteEditor.h @@ -0,0 +1,71 @@ +#ifndef ATLAS_GRAPHITE_EDITOR_H +#define ATLAS_GRAPHITE_EDITOR_H + +#include +#include +#include +#include + +class GraphiteCanvas; +class QLabel; +class QTreeWidget; +class QTreeWidgetItem; +class QVBoxLayout; +class QUndoStack; +class ViewportPanel; + +class GraphiteEditorPanel : public QWidget { + Q_OBJECT + + public: + explicit GraphiteEditorPanel(ViewportPanel *viewport, + const QString &projectFile, + QWidget *parent = nullptr); + ~GraphiteEditorPanel() override; + + void openUI(const QString &path); + void saveUI(); + void undo(); + void redo(); + + signals: + void previewRequested(); + + private: + void showEmptyState(); + void rebuildTree(); + void rebuildInspector(); + void refreshDocument(bool recordUndo = true); + void addElement(const QString &type); + void deleteSelectedElement(); + void duplicateSelectedElement(); + void addScriptComponent(); + void removeScriptComponent(); + void attachToScene(bool preview); + void setDocument(const QJsonObject &next, bool recordUndo); + QJsonObject elementAtPath(const QList &path) const; + void replaceElement(const QList &path, const QJsonObject &element); + void removeElement(const QList &path); + QList selectedPath() const; + QTreeWidgetItem *appendTreeElement(QTreeWidgetItem *parent, + const QJsonObject &element, + const QList &path); + QJsonObject defaultElement(const QString &type); + + ViewportPanel *viewport = nullptr; + QString projectFile; + QString uiPath; + QJsonObject document; + QLabel *titleLabel = nullptr; + QLabel *statusLabel = nullptr; + QTreeWidget *tree = nullptr; + GraphiteCanvas *canvas = nullptr; + QWidget *inspectorBody = nullptr; + QVBoxLayout *inspectorLayout = nullptr; + QUndoStack *undoStack = nullptr; + bool loading = false; + int nextElementNumber = 1; + QString styleVariant = "normal"; +}; + +#endif diff --git a/include/editor/views/hierarchyPanel.h b/include/editor/views/hierarchyPanel.h index c4ce149f..3ef28597 100644 --- a/include/editor/views/hierarchyPanel.h +++ b/include/editor/views/hierarchyPanel.h @@ -46,6 +46,7 @@ class HierarchyPanel : public QWidget { void objectActivated(int id); void cameraActivated(); void environmentActivated(); + void graphiteActivated(const QString &path); protected: bool eventFilter(QObject *watched, QEvent *event) override; @@ -53,13 +54,14 @@ class HierarchyPanel : public QWidget { private: void applySceneSnapshot(const QString &snapshot); void rebuildScene(const QString &sceneName, const QJsonArray &objects, - int selectedId); + const QJsonArray &interfaces, int selectedId); void appendObjects(QStandardItem *parent, const QJsonArray &objects); void showAddObjectMenu(const QPoint &position); void showContextMenu(const QPoint &position); int selectedObjectId() const; QString sceneSignature(const QString &sceneName, - const QJsonArray &objects) const; + const QJsonArray &objects, + const QJsonArray &interfaces) const; ViewportPanel *viewport = nullptr; QTreeView *treeView = nullptr; diff --git a/include/graphite/layout.h b/include/graphite/layout.h index ea917b63..796263ac 100644 --- a/include/graphite/layout.h +++ b/include/graphite/layout.h @@ -94,6 +94,7 @@ class Column : public UIObject { position(pos) { recalculatePositions(); } + void initialize() override; /** @brief Renders the column background and all children. */ void render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) override; @@ -211,6 +212,8 @@ class Row : public UIObject { recalculatePositions(); } + void initialize() override; + /** @brief Renders the row background and all children. */ void render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) override; @@ -332,6 +335,8 @@ class Stack : public UIObject { recalculatePositions(); } + void initialize() override; + /** @brief Renders the stack background and all children. */ void render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) override; diff --git a/runtime/lib/context.cpp b/runtime/lib/context.cpp index 85057acd..1c9695a0 100644 --- a/runtime/lib/context.cpp +++ b/runtime/lib/context.cpp @@ -24,6 +24,10 @@ #include "aurora/terrain.h" #include "atlas/runtime/atlasScripts.h" #include "hydra/fluid.h" +#include "graphite/image.h" +#include "graphite/input.h" +#include "graphite/layout.h" +#include "graphite/text.h" #include #include #include @@ -40,6 +44,7 @@ #include #include #include +#include #include #include @@ -3719,6 +3724,230 @@ std::shared_ptr parseEffect(const json &effectData) { throw std::runtime_error("Unknown render target effect type: " + type); } +void applyGraphiteStyleVariant(const json &data, + graphite::UIStyleVariant &variant) { + if (!data.is_object()) { + return; + } + Position2d padding; + if (tryReadVec2Any(data, {"padding"}, padding)) { + variant.padding(Size2d{padding.x, padding.y}); + } + float value = 0.0f; + if (tryReadFloatAny(data, {"cornerRadius"}, value)) { + variant.cornerRadius(value); + } + Color color; + if (tryReadColorAny(data, {"background", "backgroundColor"}, color)) { + variant.background(color); + } + float borderWidth = 0.0f; + Color borderColor; + const bool hasBorderWidth = + tryReadFloatAny(data, {"borderWidth"}, borderWidth); + const bool hasBorderColor = + tryReadColorAny(data, {"border", "borderColor"}, borderColor); + if (hasBorderWidth || hasBorderColor) { + variant.border(hasBorderWidth ? borderWidth : 1.0f, + hasBorderColor ? borderColor : Color::white()); + } + if (tryReadColorAny(data, {"foreground", "foregroundColor", "color"}, + color)) { + variant.foreground(color); + } + if (tryReadColorAny(data, {"tint", "tintColor"}, color)) { + variant.tint(color); + } + if (tryReadFloatAny(data, {"fontSize"}, value)) { + variant.fontSize(value); + } +} + +graphite::UIStyle parseGraphiteStyle(const json &data) { + graphite::UIStyle style; + if (!data.is_object()) { + return style; + } + const std::array, 6> + variants{{{"normal", graphite::UIStyleState::Normal}, + {"hovered", graphite::UIStyleState::Hovered}, + {"pressed", graphite::UIStyleState::Pressed}, + {"focused", graphite::UIStyleState::Focused}, + {"disabled", graphite::UIStyleState::Disabled}, + {"checked", graphite::UIStyleState::Checked}}}; + bool foundVariant = false; + for (const auto &[name, state] : variants) { + auto it = data.find(name); + if (it == data.end()) { + continue; + } + foundVariant = true; + applyGraphiteStyleVariant(*it, style.variant(state)); + } + if (!foundVariant) { + applyGraphiteStyleVariant(data, style.normal()); + } + return style; +} + +Font loadGraphiteFont(const json &data, const std::string &baseDir) { + if (!data.is_object()) { + return {}; + } + std::string source; + tryReadStringAny(data, {"source", "path"}, source); + if (source.empty()) { + return {}; + } + int size = 24; + tryReadIntAny(data, {"size"}, size); + size = std::max(size, 1); + const std::string resolved = resolveRuntimePath(baseDir, source); + std::string name; + tryReadStringAny(data, {"name", "id"}, name); + if (name.empty()) { + name = "graphite:" + resolved + ":" + std::to_string(size); + } + static std::unordered_map cache; + const std::string key = resolved + "#" + std::to_string(size); + if (const auto found = cache.find(key); found != cache.end()) { + return found->second; + } + Resource resource = createRuntimeResource(baseDir, source, + ResourceType::Font, + "graphite-font"); + Font font = Font::fromResource(name, resource, size); + cache[key] = font; + return font; +} + +LayoutAnchor parseGraphiteAnchor(const json &data) { + std::string value; + if (data.is_string()) { + value = normalizeToken(data.get()); + } + if (value == "topcenter") + return LayoutAnchor::TopCenter; + if (value == "topright") + return LayoutAnchor::TopRight; + if (value == "centerleft") + return LayoutAnchor::CenterLeft; + if (value == "center") + return LayoutAnchor::Center; + if (value == "centerright") + return LayoutAnchor::CenterRight; + if (value == "bottomleft") + return LayoutAnchor::BottomLeft; + if (value == "bottomcenter") + return LayoutAnchor::BottomCenter; + if (value == "bottomright") + return LayoutAnchor::BottomRight; + return LayoutAnchor::TopLeft; +} + +ElementAlignment parseGraphiteAlignment(const json &data) { + std::string value; + if (data.is_string()) { + value = normalizeToken(data.get()); + } + if (value == "center" || value == "middle") + return ElementAlignment::Center; + if (value == "bottom" || value == "right" || value == "end") + return ElementAlignment::Bottom; + return ElementAlignment::Top; +} + +void inheritGraphiteDocumentDefaults(json &element, const json &font) { + if (!element.is_object()) { + return; + } + if (!element.contains("font") && font.is_object()) { + element["font"] = font; + } + auto children = element.find("children"); + if (children == element.end() || !children->is_array()) { + return; + } + for (auto &child : *children) { + inheritGraphiteDocumentDefaults(child, font); + } +} + +void repairGraphiteColors(json &value, const std::string &key = {}) { + if (value.is_object()) { + for (auto iterator = value.begin(); iterator != value.end(); ++iterator) + repairGraphiteColors(iterator.value(), iterator.key()); + return; + } + if (!value.is_array()) + return; + const std::string normalizedKey = normalizeToken(key); + const bool colorField = normalizedKey == "background" || + normalizedKey == "foreground" || + normalizedKey == "border" || + normalizedKey == "tint" || + normalizedKey == "color" || + normalizedKey.ends_with("color"); + if (colorField && value.size() == 4 && value[3].is_number() && + std::abs(value[3].get() - (1.0 / 255.0)) < 0.00001) + value[3] = 1.0; + for (auto &entry : value) + repairGraphiteColors(entry); +} + +JsonDefinition loadGraphiteDocument(const json &value, + const std::string &baseDir) { + JsonDefinition definition; + bool enabled = true; + if (value.is_object()) { + JSON_READ_BOOL(value, "enabled", enabled); + std::string source; + tryReadStringAny(value, {"source"}, source); + if (!source.empty()) { + definition = loadJsonDefinition(source, baseDir); + } else { + definition = {.data = value, .baseDir = baseDir}; + } + } else { + definition = loadJsonDefinition(value, baseDir); + } + if (!enabled) { + definition.data = json::object(); + return definition; + } + if (!definition.data.is_object()) { + throw std::runtime_error("Graphite UI document must be an object"); + } + std::string format; + tryReadStringAny(definition.data, {"format"}, format); + if (!format.empty() && normalizeToken(format) != "atlasgraphiteui") { + throw std::runtime_error("Unsupported Graphite UI document format: " + + format); + } + int version = 1; + tryReadIntAny(definition.data, {"version"}, version); + if (version != 1) { + throw std::runtime_error("Unsupported Graphite UI document version: " + + std::to_string(version)); + } + repairGraphiteColors(definition.data); + const json defaultFont = + definition.data.contains("defaultFont") && + definition.data["defaultFont"].is_object() + ? definition.data["defaultFont"] + : json::object(); + if (definition.data.contains("root")) { + inheritGraphiteDocumentDefaults(definition.data["root"], defaultFont); + } + if (definition.data.contains("elements") && + definition.data["elements"].is_array()) { + for (auto &element : definition.data["elements"]) { + inheritGraphiteDocumentDefaults(element, defaultFont); + } + } + return definition; +} + std::shared_ptr createRenderable(Context &context, const json &objectData, const std::string &baseDir, @@ -3738,6 +3967,241 @@ createRenderable(Context &context, const json &objectData, const std::string normalizedType = normalizeToken(type); const size_t generatedIndex = context.objects.size(); + if (normalizedType == "text" || normalizedType == "image" || + normalizedType == "button" || normalizedType == "checkbox" || + normalizedType == "textfield" || normalizedType == "column" || + normalizedType == "row" || normalizedType == "stack") { + Position2d position{0.0f, 0.0f}; + tryReadVec2Any(objectData, {"position"}, position); + Position2d size{0.0f, 0.0f}; + tryReadVec2Any(objectData, {"size", "minimumSize"}, size); + Font font; + if (const json *fontData = findField(objectData, {"font"}); + fontData != nullptr) { + font = loadGraphiteFont(*fontData, baseDir); + } + graphite::UIStyle style; + const bool hasStyle = objectData.contains("style") && + objectData["style"].is_object(); + if (hasStyle) { + style = parseGraphiteStyle(objectData["style"]); + } + auto registerUIObject = [&](const auto &object) { + registerGameObject(context, *object, objectData, normalizedType, + generatedIndex); + context.objects.push_back(object); + collectPendingComponents(context, *object, objectData, baseDir, + rigidbodies, standard, joints); + return std::static_pointer_cast(object); + }; + + if (normalizedType == "text") { + std::string content; + tryReadStringAny(objectData, {"content", "text"}, content); + Color color = Color::white(); + tryReadColorAny(objectData, {"color", "textColor"}, color); + auto object = std::make_shared(content, font, color, position); + tryReadFloatAny(objectData, {"fontSize"}, object->fontSize); + if (hasStyle) + object->setStyle(style); + return registerUIObject(object); + } + + if (normalizedType == "image") { + auto object = std::make_shared(); + object->position = position; + object->size = Size2d{size.x, size.y}; + tryReadColorAny(objectData, {"tint"}, object->tint); + if (const json *source = findField(objectData, {"source", "texture"}); + source != nullptr && !isEmptyStringValue(*source)) { + object->texture = loadTextureDefinition( + *source, baseDir, TextureType::Color, false); + } + if (hasStyle) + object->setStyle(style); + return registerUIObject(object); + } + + if (normalizedType == "button") { + std::string label; + tryReadStringAny(objectData, {"label", "content", "text"}, label); + auto object = std::make_shared