MetaCore/Source/MetaCoreRuntimeUi/Private/MetaCoreUiCompiler.cpp
2026-07-27 10:36:59 +08:00

256 lines
14 KiB
C++

#include "MetaCoreRuntimeUi/MetaCoreRuntimeUiSystem.h"
#include <algorithm>
#include <cmath>
#include <functional>
#include <iomanip>
#include <sstream>
#include <unordered_map>
#include <unordered_set>
namespace MetaCore {
namespace {
std::string Escape(std::string_view value) {
std::string result;
result.reserve(value.size());
for (char c : value) {
switch (c) {
case '&': result += "&amp;"; break;
case '<': result += "&lt;"; break;
case '>': result += "&gt;"; break;
case '"': result += "&quot;"; break;
case '\'': result += "&#39;"; break;
default: result += c; break;
}
}
return result;
}
const char* NodeClass(MetaCoreUiNodeType type) {
switch (type) {
case MetaCoreUiNodeType::Panel: return "panel";
case MetaCoreUiNodeType::Text: return "text";
case MetaCoreUiNodeType::Image: return "image";
case MetaCoreUiNodeType::Button: return "button";
case MetaCoreUiNodeType::Input: return "input";
case MetaCoreUiNodeType::List: return "list";
case MetaCoreUiNodeType::Scroll: return "scroll";
}
return "invalid";
}
const char* BindingTargetName(MetaCoreUiBindingTarget target) {
switch (target) {
case MetaCoreUiBindingTarget::Text: return "text";
case MetaCoreUiBindingTarget::Value: return "value";
case MetaCoreUiBindingTarget::Visible: return "visible";
case MetaCoreUiBindingTarget::Enabled: return "enabled";
case MetaCoreUiBindingTarget::Items: return "items";
case MetaCoreUiBindingTarget::SelectedIndex: return "selected-index";
}
return "text";
}
std::string Color(const glm::vec3& value, float alpha = 1.0F) {
std::ostringstream out;
out << "rgba(" << static_cast<int>(std::clamp(value.x, 0.0F, 1.0F) * 255.0F) << ','
<< static_cast<int>(std::clamp(value.y, 0.0F, 1.0F) * 255.0F) << ','
<< static_cast<int>(std::clamp(value.z, 0.0F, 1.0F) * 255.0F) << ','
<< std::fixed << std::setprecision(3) << std::clamp(alpha, 0.0F, 1.0F) << ')';
return out.str();
}
bool IsBindingPathValid(std::string_view path) {
return path.starts_with("runtime.") || path.starts_with("script.") || path.starts_with("scene.");
}
} // namespace
MetaCoreUiDocument MetaCoreMigrateUiDocument(MetaCoreUiDocument document) {
if (document.SchemaVersion >= METACORE_UI_SCHEMA_VERSION) {
return document;
}
for (MetaCoreUiNodeDocument& node : document.Nodes) {
if (!node.DataBinding.empty() && node.Bindings.empty()) {
node.Bindings.push_back({
node.DataBinding,
node.Type == MetaCoreUiNodeType::Input ? MetaCoreUiBindingTarget::Value : MetaCoreUiBindingTarget::Text,
node.Type == MetaCoreUiNodeType::Input ? MetaCoreUiBindingMode::TwoWay : MetaCoreUiBindingMode::OneWay,
node.DataFormat,
{},
{}
});
}
}
document.SchemaVersion = METACORE_UI_SCHEMA_VERSION;
document.GeneratorVersion = METACORE_UI_GENERATOR_VERSION;
return document;
}
std::vector<MetaCoreUiDiagnostic> MetaCoreValidateUiDocument(const MetaCoreUiDocument& document) {
std::vector<MetaCoreUiDiagnostic> diagnostics;
auto issue = [&](std::string node, std::string binding, std::string message) {
diagnostics.push_back({document.Name, std::move(node), std::move(binding), std::move(message), true});
};
if (document.SchemaVersion > METACORE_UI_SCHEMA_VERSION) issue({}, {}, "UI document schema is newer than this engine");
if (document.GeneratorVersion > METACORE_UI_GENERATOR_VERSION) issue({}, {}, "UI document generator is newer than this engine");
if (document.ReferenceWidth <= 0 || document.ReferenceHeight <= 0) issue({}, {}, "Reference resolution must be positive");
if (!std::isfinite(document.MatchWidthOrHeight) || document.MatchWidthOrHeight < 0.0F || document.MatchWidthOrHeight > 1.0F)
issue({}, {}, "MatchWidthOrHeight must be in [0, 1]");
if (document.ReferenceDpi <= 0.0F || document.MinScale <= 0.0F || document.MaxScale < document.MinScale)
issue({}, {}, "Canvas DPI or scale limits are invalid");
std::unordered_map<std::string, const MetaCoreUiNodeDocument*> nodes;
for (const MetaCoreUiNodeDocument& node : document.Nodes) {
if (node.Id.empty()) issue({}, {}, "Every UI node requires a stable ID");
else if (!nodes.emplace(node.Id, &node).second) issue(node.Id, {}, "Duplicate UI node ID");
if (node.RectTransform.MinSize.x < 0.0F || node.RectTransform.MinSize.y < 0.0F ||
node.RectTransform.MaxSize.x < node.RectTransform.MinSize.x ||
node.RectTransform.MaxSize.y < node.RectTransform.MinSize.y)
issue(node.Id, {}, "Node minimum/maximum size is invalid");
if (node.Type == MetaCoreUiNodeType::Input && node.MaxLength < 0) issue(node.Id, {}, "Input MaxLength cannot be negative");
for (const MetaCoreUiBindingDocument& binding : node.Bindings) {
if (!IsBindingPathValid(binding.SourcePath)) issue(node.Id, binding.SourcePath, "Binding path must use runtime., script. or scene.");
if (binding.Mode == MetaCoreUiBindingMode::TwoWay &&
binding.Target != MetaCoreUiBindingTarget::Value &&
binding.Target != MetaCoreUiBindingTarget::SelectedIndex)
issue(node.Id, binding.SourcePath, "Two-way binding is only valid for Value or SelectedIndex");
}
}
for (const std::string& root : document.RootNodeIds) {
if (!nodes.contains(root)) issue(root, {}, "Root node does not exist");
else if (!nodes.at(root)->ParentId.empty()) issue(root, {}, "Root node has a parent");
}
for (const MetaCoreUiNodeDocument& node : document.Nodes) {
if (!node.ParentId.empty() && !nodes.contains(node.ParentId)) issue(node.Id, {}, "Parent node does not exist");
std::unordered_set<std::string> children;
for (const std::string& child : node.Children) {
if (!children.insert(child).second) issue(node.Id, {}, "Child ID is duplicated");
if (!nodes.contains(child)) issue(node.Id, {}, "Child node does not exist: " + child);
else if (nodes.at(child)->ParentId != node.Id) issue(child, {}, "Parent/child relationship is inconsistent");
}
if (!node.ItemTemplateNodeId.empty() && !nodes.contains(node.ItemTemplateNodeId))
issue(node.Id, {}, "List item template node does not exist");
}
enum class Visit { Visiting, Done };
std::unordered_map<std::string, Visit> visited;
std::function<void(const std::string&)> walk = [&](const std::string& id) {
const auto state = visited.find(id);
if (state != visited.end()) {
if (state->second == Visit::Visiting) issue(id, {}, "UI hierarchy contains a cycle");
return;
}
visited[id] = Visit::Visiting;
const auto found = nodes.find(id);
if (found != nodes.end()) for (const std::string& child : found->second->Children) walk(child);
visited[id] = Visit::Done;
};
for (const std::string& root : document.RootNodeIds) walk(root);
for (const auto& [id, _] : nodes) if (!visited.contains(id)) issue(id, {}, "Node is unreachable from RootNodeIds");
return diagnostics;
}
MetaCoreCompiledUiDocument MetaCoreCompileUiDocument(const MetaCoreUiDocument& source) {
MetaCoreCompiledUiDocument result;
const MetaCoreUiDocument document = MetaCoreMigrateUiDocument(source);
result.Diagnostics = MetaCoreValidateUiDocument(document);
if (std::any_of(result.Diagnostics.begin(), result.Diagnostics.end(), [](const auto& value) { return value.IsError; })) return result;
std::unordered_map<std::string, const MetaCoreUiNodeDocument*> nodes;
for (const auto& node : document.Nodes) {
nodes[node.Id] = &node;
if (node.Style.ImageAssetGuid.IsValid()) result.Dependencies.push_back(node.Style.ImageAssetGuid);
}
for (MetaCoreAssetGuid guid : {document.ThemeAssetGuid, document.FontFamilyAssetGuid, document.LocalizationAssetGuid})
if (guid.IsValid()) result.Dependencies.push_back(guid);
std::sort(result.Dependencies.begin(), result.Dependencies.end(), [](const auto& a, const auto& b) { return a.ToString() < b.ToString(); });
result.Dependencies.erase(std::unique(result.Dependencies.begin(), result.Dependencies.end()), result.Dependencies.end());
std::ostringstream rml;
rml << "<rml><head><link type=\"text/rcss\" href=\"document.rcss\"/></head><body"
<< " data-metacore-schema=\"" << METACORE_UI_SCHEMA_VERSION << "\""
<< " data-metacore-scale-mode=\"" << static_cast<int>(document.ScaleMode) << "\""
<< " data-metacore-reference=\"" << document.ReferenceWidth << 'x' << document.ReferenceHeight << "\""
<< " data-metacore-match=\"" << document.MatchWidthOrHeight << "\""
<< " data-metacore-reference-dpi=\"" << document.ReferenceDpi << "\""
<< " data-metacore-safe-area=\"" << (document.UseSafeArea ? "true" : "false") << "\">\n";
std::function<void(const std::string&, int)> emit = [&](const std::string& id, int depth) {
const MetaCoreUiNodeDocument& node = *nodes.at(id);
const std::string indent(static_cast<std::size_t>(depth), ' ');
const char* tag = "div";
if (node.Type == MetaCoreUiNodeType::Button) tag = "button";
else if (node.Type == MetaCoreUiNodeType::Input) tag = "input";
else if (node.Type == MetaCoreUiNodeType::Image) tag = "img";
rml << indent << '<' << tag << " id=\"" << Escape(node.Id) << "\" class=\"mcui mcui-" << NodeClass(node.Type);
if (!node.Style.ThemeToken.empty()) rml << " theme-" << Escape(node.Style.ThemeToken);
rml << "\" data-metacore-kind=\"" << NodeClass(node.Type) << "\"";
if (!node.Action.empty()) rml << " data-metacore-action=\"" << Escape(node.Action) << "\"";
if (node.NavigationOrder >= 0) rml << " tabindex=\"" << node.NavigationOrder << "\"";
if (!node.LocalizationKey.empty()) rml << " data-metacore-loc=\"" << Escape(node.LocalizationKey) << "\"";
if (!node.ShowAnimation.empty()) rml << " data-metacore-show-animation=\"" << Escape(node.ShowAnimation) << "\"";
if (!node.HideAnimation.empty()) rml << " data-metacore-hide-animation=\"" << Escape(node.HideAnimation) << "\"";
for (const auto& binding : node.Bindings) {
rml << " data-metacore-bind-" << BindingTargetName(binding.Target) << "=\"" << Escape(binding.SourcePath) << "\""
<< " data-metacore-bind-mode-" << BindingTargetName(binding.Target) << "=\""
<< (binding.Mode == MetaCoreUiBindingMode::TwoWay ? "two-way" : "one-way") << "\"";
if (!binding.Format.empty()) rml << " data-metacore-format-" << BindingTargetName(binding.Target) << "=\"" << Escape(binding.Format) << "\"";
if (!binding.Fallback.empty()) rml << " data-metacore-fallback-" << BindingTargetName(binding.Target) << "=\"" << Escape(binding.Fallback) << "\"";
}
if (node.Bindings.empty() && !node.DataBinding.empty()) {
rml << " data-metacore-bind-text=\"" << Escape(node.DataBinding) << "\" data-metacore-format-text=\"" << Escape(node.DataFormat) << "\"";
}
if (!node.Visible) rml << " hidden=\"true\"";
if (!node.Interactable && (node.Type == MetaCoreUiNodeType::Button || node.Type == MetaCoreUiNodeType::Input)) rml << " disabled=\"true\"";
if (node.Type == MetaCoreUiNodeType::Input) {
rml << " value=\"" << Escape(node.Text) << "\" placeholder=\"" << Escape(node.Placeholder) << "\"";
if (node.ReadOnly) rml << " readonly=\"true\"";
if (node.MaxLength > 0) rml << " maxlength=\"" << node.MaxLength << "\"";
rml << " type=\"" << (node.InputValueType == MetaCoreUiInputValueType::Text ? "text" : "number") << "\"/>\n";
return;
}
if (node.Type == MetaCoreUiNodeType::Image && node.Style.ImageAssetGuid.IsValid())
rml << " src=\"Assets/" << node.Style.ImageAssetGuid.ToString() << ".png\"";
if (node.Type == MetaCoreUiNodeType::List) rml << " role=\"listbox\" data-metacore-selected-index=\"" << node.SelectedIndex << "\"";
rml << '>';
if (node.Type == MetaCoreUiNodeType::Text || node.Type == MetaCoreUiNodeType::Button) rml << Escape(node.Text);
if (!node.Children.empty()) rml << '\n';
for (const std::string& child : node.Children) emit(child, depth + 2);
if (!node.Children.empty()) rml << indent;
rml << "</" << tag << ">\n";
};
for (const std::string& root : document.RootNodeIds) emit(root, 2);
rml << "</body></rml>\n";
std::ostringstream css;
css << "body { margin:0; width:100%; height:100%; background-color:" << Color(document.BackgroundColor, document.BackgroundAlpha) << "; }\n"
<< ".mcui { position:absolute; box-sizing:border-box; }\n"
<< ".mcui-button:focus, .mcui-input:focus, .mcui-list:focus { outline:2px #4da3ff; }\n"
<< ".mcui-button:disabled, .mcui-input:disabled { opacity:0.45; }\n"
<< ".mcui-scroll { overflow:auto; }\n.mcui-list { overflow:auto; }\n";
for (const auto& node : document.Nodes) {
const auto& rt = node.RectTransform;
const float leftOffset = rt.Position.x - rt.Pivot.x * rt.Size.x;
const float topOffset = rt.Position.y - rt.Pivot.y * rt.Size.y;
const float anchorWidth = (rt.AnchorMax.x - rt.AnchorMin.x) * 100.0F;
const float anchorHeight = (rt.AnchorMax.y - rt.AnchorMin.y) * 100.0F;
css << '#' << node.Id << " { left:calc(" << rt.AnchorMin.x * 100.0F << "% + " << leftOffset
<< "px); top:calc(" << rt.AnchorMin.y * 100.0F << "% + " << topOffset << "px); width:calc("
<< anchorWidth << "% + " << rt.Size.x << "px); height:calc(" << anchorHeight << "% + " << rt.Size.y
<< "px); min-width:" << rt.MinSize.x << "px; min-height:" << rt.MinSize.y << "px; max-width:"
<< rt.MaxSize.x << "px; max-height:" << rt.MaxSize.y << "px; color:" << Color(node.Style.TextColor)
<< "; background-color:" << Color(node.Style.BackgroundColor, node.Style.Opacity) << "; font-size:"
<< node.Style.FontSize << "px; padding:" << node.Style.Padding.y << "px " << node.Style.Padding.x
<< "px; border-radius:" << node.Style.BorderRadius << "px; overflow:"
<< (rt.ClipChildren ? "hidden" : (node.Type == MetaCoreUiNodeType::Scroll || node.Type == MetaCoreUiNodeType::List ? "auto" : "visible"))
<< "; }\n";
}
result.Rml = rml.str();
result.Rcss = css.str();
result.Succeeded = true;
return result;
}
} // namespace MetaCore