256 lines
14 KiB
C++
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 += "&"; break;
|
|
case '<': result += "<"; break;
|
|
case '>': result += ">"; break;
|
|
case '"': result += """; break;
|
|
case '\'': result += "'"; 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
|