#include "MetaCoreRuntimeUi/MetaCoreRuntimeUiSystem.h" #include #include #include #include #include #include #include #include #include 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; } std::string EscapeJson(std::string_view value) { std::string result; result.reserve(value.size()); for (const unsigned char c : value) { switch (c) { case '\\': result += "\\\\"; break; case '"': result += "\\\""; break; case '\n': result += "\\n"; break; case '\r': result += "\\r"; break; case '\t': result += "\\t"; break; default: if (c < 0x20U) { constexpr char digits[] = "0123456789abcdef"; result += "\\u00"; result += digits[c >> 4U]; result += digits[c & 0x0fU]; } else { result += static_cast(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(std::clamp(value.x, 0.0F, 1.0F) * 255.0F) << ',' << static_cast(std::clamp(value.y, 0.0F, 1.0F) * 255.0F) << ',' << static_cast(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."); } const char* EasingName(MetaCoreUiEasing easing) { switch (easing) { case MetaCoreUiEasing::Linear: return "linear"; case MetaCoreUiEasing::EaseIn: return "ease-in"; case MetaCoreUiEasing::EaseOut: return "ease-out"; case MetaCoreUiEasing::EaseInOut: return "ease-in-out"; } return "linear"; } const char* ThemeStateSelector(MetaCoreUiVisualState state) { switch (state) { case MetaCoreUiVisualState::Normal: return ""; case MetaCoreUiVisualState::Hover: return ":hover"; case MetaCoreUiVisualState::Pressed: return ":active"; case MetaCoreUiVisualState::Focused: return ":focus"; case MetaCoreUiVisualState::Disabled: return ":disabled"; } return ""; } std::string AnimationValue(MetaCoreUiAnimationProperty property, const glm::vec3& value) { std::ostringstream out; switch (property) { case MetaCoreUiAnimationProperty::Opacity: out << std::clamp(value.x, 0.0F, 1.0F); break; case MetaCoreUiAnimationProperty::Translation: out << "translate(" << value.x << "px," << value.y << "px)"; break; case MetaCoreUiAnimationProperty::Scale: out << "scale(" << value.x << ',' << value.y << ')'; break; case MetaCoreUiAnimationProperty::Color: return Color(value); } return out.str(); } const char* AnimationPropertyName(MetaCoreUiAnimationProperty property) { switch (property) { case MetaCoreUiAnimationProperty::Opacity: return "opacity"; case MetaCoreUiAnimationProperty::Translation: case MetaCoreUiAnimationProperty::Scale: return "transform"; case MetaCoreUiAnimationProperty::Color: return "color"; } return "opacity"; } std::string Trim(std::string_view value) { std::size_t first = 0; while (first < value.size() && std::isspace(static_cast(value[first]))) ++first; std::size_t last = value.size(); while (last > first && std::isspace(static_cast(value[last - 1]))) --last; return std::string(value.substr(first, last - first)); } std::string Lower(std::string_view value) { std::string result(value); std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return result; } std::string DecodeLegacyText(std::string_view value) { std::string result(value); const std::pair entities[] = { {"<", "<"}, {">", ">"}, {""", "\""}, {"'", "'"}, {"&", "&"} }; for (const auto& [encoded, decoded] : entities) { std::size_t position = 0; while ((position = result.find(encoded, position)) != std::string::npos) { result.replace(position, encoded.size(), decoded); position += decoded.size(); } } return result; } std::string LegacyAttribute(std::string_view tag, std::string_view requestedName) { const std::string name = Lower(requestedName); std::size_t position = 0; while (position < tag.size()) { while (position < tag.size() && (std::isspace(static_cast(tag[position])) || tag[position] == '<')) ++position; const std::size_t keyStart = position; while (position < tag.size() && (std::isalnum(static_cast(tag[position])) || tag[position] == '-' || tag[position] == '_' || tag[position] == ':')) ++position; if (position == keyStart) { ++position; continue; } const std::string key = Lower(tag.substr(keyStart, position - keyStart)); while (position < tag.size() && std::isspace(static_cast(tag[position]))) ++position; if (position >= tag.size() || tag[position] != '=') continue; ++position; while (position < tag.size() && std::isspace(static_cast(tag[position]))) ++position; if (position >= tag.size()) break; const char quote = tag[position]; if (quote != '"' && quote != '\'') continue; const std::size_t valueStart = ++position; const std::size_t valueEnd = tag.find(quote, valueStart); if (valueEnd == std::string_view::npos) break; if (key == name) return DecodeLegacyText(tag.substr(valueStart, valueEnd - valueStart)); position = valueEnd + 1; } return {}; } std::optional LegacyNodeType(std::string_view tag, std::string_view fullTag) { const std::string explicitType = Lower(LegacyAttribute(fullTag, "data-metacore-type")); const std::string name = explicitType.empty() ? Lower(tag) : explicitType; if (name == "div" || name == "section" || name == "panel") return MetaCoreUiNodeType::Panel; if (name == "p" || name == "span" || name == "label" || name == "text") return MetaCoreUiNodeType::Text; if (name == "img" || name == "image") return MetaCoreUiNodeType::Image; if (name == "button") return MetaCoreUiNodeType::Button; if (name == "input" || name == "textarea") return MetaCoreUiNodeType::Input; if (name == "select" || name == "ul" || name == "ol" || name == "list") return MetaCoreUiNodeType::List; if (name == "scroll") return MetaCoreUiNodeType::Scroll; return std::nullopt; } } // namespace MetaCoreLegacyUiImportResult MetaCoreImportLegacyUiDocument( std::string_view rml, std::string_view rcss, std::string_view documentName ) { MetaCoreLegacyUiImportResult result; result.Document.Name = Trim(documentName).empty() ? "ImportedUi" : Trim(documentName); result.Document.SchemaVersion = METACORE_UI_SCHEMA_VERSION; result.Document.GeneratorVersion = METACORE_UI_GENERATOR_VERSION; MetaCoreUiNodeDocument root; root.Id = "LegacyRoot"; root.Name = "LegacyRoot"; root.Type = MetaCoreUiNodeType::Panel; root.RectTransform.AnchorMax = {1.0F, 1.0F, 0.0F}; root.RectTransform.Size = {0.0F, 0.0F, 0.0F}; result.Document.Nodes.push_back(root); result.Document.RootNodeIds.push_back(root.Id); struct OpenNode { std::string Tag{}; std::string NodeId{}; }; std::vector stack{{"__root", root.Id}}; std::unordered_set ids{root.Id}; std::size_t generatedId = 0; auto diagnostic = [&](std::string node, std::string message, bool error = false) { result.Diagnostics.push_back({result.Document.Name, std::move(node), {}, std::move(message), error}); }; auto appendText = [&](std::string_view rawText) { const std::string text = Trim(DecodeLegacyText(rawText)); if (text.empty() || stack.size() <= 1) return; const std::string& nodeId = stack.back().NodeId; const auto node = std::find_if(result.Document.Nodes.begin(), result.Document.Nodes.end(), [&](const MetaCoreUiNodeDocument& candidate) { return candidate.Id == nodeId; }); if (node != result.Document.Nodes.end() && (node->Type == MetaCoreUiNodeType::Text || node->Type == MetaCoreUiNodeType::Button || node->Type == MetaCoreUiNodeType::Input)) { if (!node->Text.empty()) node->Text += ' '; node->Text += text; } }; std::size_t position = 0; while (position < rml.size()) { const std::size_t tagStart = rml.find('<', position); if (tagStart == std::string_view::npos) { appendText(rml.substr(position)); break; } appendText(rml.substr(position, tagStart - position)); if (rml.substr(tagStart).starts_with("", tagStart + 4); if (commentEnd == std::string_view::npos) { diagnostic({}, "Legacy RML contains an unterminated comment", true); break; } position = commentEnd + 3; continue; } const std::size_t tagEnd = rml.find('>', tagStart + 1); if (tagEnd == std::string_view::npos) { diagnostic({}, "Legacy RML contains an unterminated tag", true); break; } const std::string_view fullTag = rml.substr(tagStart, tagEnd - tagStart + 1); std::string_view body = fullTag.substr(1, fullTag.size() - 2); const bool closing = !body.empty() && body.front() == '/'; if (closing) body.remove_prefix(1); body = std::string_view(body.data(), body.size()); std::size_t nameLength = 0; while (nameLength < body.size() && (std::isalnum(static_cast(body[nameLength])) || body[nameLength] == '-')) ++nameLength; const std::string tagName = Lower(body.substr(0, nameLength)); const bool selfClosing = (!body.empty() && body.back() == '/') || tagName == "input" || tagName == "img" || tagName == "br" || tagName == "link" || tagName == "meta"; position = tagEnd + 1; if (tagName.empty() || tagName == "!doctype" || tagName == "rml" || tagName == "html" || tagName == "head" || tagName == "body" || tagName == "title" || tagName == "style" || tagName == "link" || tagName == "meta") { continue; } if (closing) { const auto open = std::find_if(stack.rbegin(), stack.rend(), [&](const OpenNode& candidate) { return candidate.Tag == tagName; }); if (open == stack.rend()) { diagnostic({}, "Ignored unmatched closing tag "); } else { stack.erase(open.base() - 1, stack.end()); } continue; } const std::optional type = LegacyNodeType(tagName, fullTag); if (!type.has_value()) { diagnostic({}, "Unsupported legacy tag <" + tagName + "> was skipped"); continue; } std::string id = LegacyAttribute(fullTag, "id"); if (id.empty() || ids.contains(id)) { if (!id.empty()) diagnostic(id, "Duplicate legacy id was replaced with a deterministic generated id"); do id = "LegacyNode" + std::to_string(++generatedId); while (ids.contains(id)); } ids.insert(id); MetaCoreUiNodeDocument node; node.Id = id; node.Name = LegacyAttribute(fullTag, "name"); if (node.Name.empty()) node.Name = id; node.ParentId = stack.back().NodeId; node.Type = *type; node.Placeholder = LegacyAttribute(fullTag, "placeholder"); node.Interactable = *type == MetaCoreUiNodeType::Button || *type == MetaCoreUiNodeType::Input || *type == MetaCoreUiNodeType::List || *type == MetaCoreUiNodeType::Scroll; if (*type == MetaCoreUiNodeType::Image) { const std::string source = LegacyAttribute(fullTag, "src"); if (!source.empty()) diagnostic(id, "Image src '" + source + "' requires manual Asset GUID assignment"); } const auto parent = std::find_if(result.Document.Nodes.begin(), result.Document.Nodes.end(), [&](const MetaCoreUiNodeDocument& candidate) { return candidate.Id == node.ParentId; }); if (parent != result.Document.Nodes.end()) parent->Children.push_back(id); result.Document.Nodes.push_back(std::move(node)); if (!selfClosing) stack.push_back({tagName, id}); } if (!rcss.empty()) { diagnostic({}, "Legacy RCSS was diagnosed but not copied; migrate declarations to UiTheme tokens and node styles"); } const std::vector validation = MetaCoreValidateUiDocument(result.Document); result.Diagnostics.insert(result.Diagnostics.end(), validation.begin(), validation.end()); result.Succeeded = std::none_of(result.Diagnostics.begin(), result.Diagnostics.end(), [](const MetaCoreUiDiagnostic& item) { return item.IsError; }); return result; } 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 MetaCoreValidateUiDocument(const MetaCoreUiDocument& document) { std::vector 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"); if (document.MinAspectRatio < 0.0F || document.MaxAspectRatio < 0.0F || (document.MinAspectRatio > 0.0F && document.MaxAspectRatio > 0.0F && document.MaxAspectRatio < document.MinAspectRatio)) issue({}, {}, "Canvas aspect ratio limits are invalid"); std::unordered_map 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"); if (node.Type != MetaCoreUiNodeType::List && (!node.StaticItems.empty() || !node.ItemTemplateNodeId.empty())) issue(node.Id, {}, "Static items and row templates are only valid on List nodes"); std::unordered_set listItemIds; for (const MetaCoreUiListItemDocument& item : node.StaticItems) { if (item.Id.empty()) issue(node.Id, {}, "Static list item requires a stable ID"); else if (!listItemIds.insert(item.Id).second) issue(node.Id, {}, "Duplicate static list item ID"); } std::unordered_set animationIds; for (const MetaCoreUiAnimationDocument& animation : node.Animations) { if (animation.Id.empty()) issue(node.Id, {}, "Animation requires a stable ID"); else if (!animationIds.insert(animation.Id).second) issue(node.Id, {}, "Duplicate animation ID"); if (!std::isfinite(animation.DurationSeconds) || animation.DurationSeconds < 0.0F || !std::isfinite(animation.DelaySeconds) || animation.DelaySeconds < 0.0F) issue(node.Id, {}, "Animation duration and delay must be finite and non-negative"); } for (const MetaCoreUiBindingDocument& binding : node.Bindings) { if (!IsBindingPathValid(binding.SourcePath)) issue(node.Id, binding.SourcePath, "Binding path must use runtime., script. or scene."); static const std::unordered_set converters{ "", "not", "to_string", "uppercase", "lowercase", "to_bool", "to_int", "to_double" }; if (!converters.contains(binding.Converter)) issue(node.Id, binding.SourcePath, "Binding converter is not supported: " + binding.Converter); 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 auto& node : document.Nodes) if (!node.ItemTemplateNodeId.empty() && !nodes.contains(node.ItemTemplateNodeId)) issue(node.Id, {}, "List row template node does not exist"); 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 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 visited; std::function 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) { return MetaCoreCompileUiDocument(source, {}); } MetaCoreCompiledUiDocument MetaCoreCompileUiDocument( const MetaCoreUiDocument& source, const MetaCoreUiCompileResources& resources ) { MetaCoreCompiledUiDocument result; const MetaCoreUiDocument document = MetaCoreMigrateUiDocument(source); result.Diagnostics = MetaCoreValidateUiDocument(document); const auto resourceIssue = [&](std::string node, std::string message) { result.Diagnostics.push_back({document.Name, std::move(node), {}, std::move(message), true}); }; if (resources.Theme.has_value()) { std::unordered_set stateKeys; for (const auto& style : resources.Theme->Styles) { const std::string key = style.Token + ':' + std::to_string(static_cast(style.State)); if (style.Token.empty()) resourceIssue({}, "Theme style requires a token"); else if (!stateKeys.insert(key).second) resourceIssue({}, "Duplicate theme token state: " + key); } } if (resources.FontFamily.has_value()) { for (const auto& face : resources.FontFamily->Faces) if (!face.FontAssetGuid.IsValid()) resourceIssue({}, "Font face requires an asset GUID"); } if (resources.Localization.has_value()) { std::unordered_set localeIds; for (const auto& locale : resources.Localization->Locales) { if (locale.Locale.empty() || !localeIds.insert(locale.Locale).second) resourceIssue({}, "Localization locale is empty or duplicated"); std::unordered_set keys; for (const auto& entry : locale.Entries) if (entry.Key.empty() || !keys.insert(entry.Key).second) resourceIssue({}, "Localization key is empty or duplicated in " + locale.Locale); } for (const auto& node : document.Nodes) { if (!node.LocalizationKey.empty() && !MetaCoreResolveUiLocalizedText(*resources.Localization, node.LocalizationKey, document.DefaultLocale, document.FallbackLocale).has_value()) resourceIssue(node.Id, "Localization key cannot be resolved: " + node.LocalizationKey); } } if (std::any_of(result.Diagnostics.begin(), result.Diagnostics.end(), [](const auto& value) { return value.IsError; })) return result; std::unordered_map 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); if (resources.FontFamily.has_value()) for (const auto& face : resources.FontFamily->Faces) if (face.FontAssetGuid.IsValid()) result.Dependencies.push_back(face.FontAssetGuid); 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 << "(document.ScaleMode) << "\"" << " data-metacore-reference=\"" << document.ReferenceWidth << 'x' << document.ReferenceHeight << "\"" << " data-metacore-match=\"" << document.MatchWidthOrHeight << "\"" << " data-metacore-reference-dpi=\"" << document.ReferenceDpi << "\"" << " data-metacore-min-scale=\"" << document.MinScale << "\"" << " data-metacore-max-scale=\"" << document.MaxScale << "\"" << " data-metacore-min-aspect=\"" << document.MinAspectRatio << "\"" << " data-metacore-max-aspect=\"" << document.MaxAspectRatio << "\"" << " data-metacore-safe-area=\"" << (document.UseSafeArea ? "true" : "false") << "\""; if (resources.Theme.has_value()) rml << " data-metacore-theme=\"" << Escape(resources.Theme->Name) << "\""; rml << ">\n"; if (resources.FontFamily.has_value()) { std::string requiredCharacters; for (const auto& face : resources.FontFamily->Faces) requiredCharacters += face.RequiredCharacters; if (!requiredCharacters.empty()) rml << " " << Escape(requiredCharacters) << "\n"; } std::int32_t documentOrder = 0; std::function emit = [&](const std::string& id, int depth) { const MetaCoreUiNodeDocument& node = *nodes.at(id); const std::int32_t currentDocumentOrder = documentOrder++; const std::string indent(static_cast(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::List) tag = "select"; else if (node.Type == MetaCoreUiNodeType::Image) { const bool isAtlased = std::any_of(resources.AtlasEntries.begin(), resources.AtlasEntries.end(), [&](const MetaCoreUiCompileResources::AtlasEntry& entry) { return entry.AssetGuid == node.Style.ImageAssetGuid; }); tag = isAtlased ? "div" : "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 << "\""; else if (node.Type == MetaCoreUiNodeType::Button || node.Type == MetaCoreUiNodeType::Input || node.Type == MetaCoreUiNodeType::List) rml << " tabindex=\"" << (100000 + currentDocumentOrder) << "\""; 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.Converter.empty()) rml << " data-metacore-converter-" << BindingTargetName(binding.Target) << "=\"" << Escape(binding.Converter) << "\""; 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 << " data-metacore-input-type=\"" << static_cast(node.InputValueType) << "\"" << " type=\"" << (node.InputValueType == MetaCoreUiInputValueType::Text ? "text" : "number") << "\""; if (node.InputValueType == MetaCoreUiInputValueType::Integer) rml << " step=\"1\""; else if (node.InputValueType == MetaCoreUiInputValueType::Number) rml << " step=\"any\""; rml << "/>\n"; return; } if (node.Type == MetaCoreUiNodeType::Image && node.Style.ImageAssetGuid.IsValid() && std::none_of(resources.AtlasEntries.begin(), resources.AtlasEntries.end(), [&](const MetaCoreUiCompileResources::AtlasEntry& entry) { return entry.AssetGuid == node.Style.ImageAssetGuid; })) 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) { std::string text = node.Text; if (resources.Localization.has_value() && !node.LocalizationKey.empty()) { text = MetaCoreResolveUiLocalizedText( *resources.Localization, node.LocalizationKey, document.DefaultLocale, document.FallbackLocale ).value_or(node.Text); } rml << Escape(text); } if (node.Type == MetaCoreUiNodeType::List) { for (std::size_t index = 0; index < node.StaticItems.size(); ++index) { const auto& item = node.StaticItems[index]; std::string text = item.Text; if (resources.Localization.has_value() && !item.LocalizationKey.empty()) text = MetaCoreResolveUiLocalizedText(*resources.Localization, item.LocalizationKey, document.DefaultLocale, document.FallbackLocale).value_or(item.Text); rml << ""; } } if (!node.Children.empty()) rml << '\n'; for (const std::string& child : node.Children) emit(child, depth + 2); if (!node.Children.empty()) rml << indent; rml << "\n"; }; for (const std::string& root : document.RootNodeIds) emit(root, 2); rml << "\n"; std::ostringstream css; if (!resources.AtlasFilename.empty() && !resources.AtlasEntries.empty()) { css << "@spritesheet metacore-ui-atlas { src:url(\"Assets/" << Escape(resources.AtlasFilename) << "\");"; for (const auto& entry : resources.AtlasEntries) { css << " sprite:" << entry.AssetGuid.ToString() << ' ' << entry.X << "px " << entry.Y << "px " << entry.Width << "px " << entry.Height << "px;"; } css << " }\n"; } if (resources.FontFamily.has_value()) { const auto& family = *resources.FontFamily; for (const auto& face : family.Faces) { if (!face.FontAssetGuid.IsValid()) continue; css << "@font-face { font-family:\"" << Escape(family.FamilyName) << "\"; src:url(\"Assets/" << face.FontAssetGuid.ToString() << ".font\"); font-weight:" << face.Weight << "; font-style:" << (face.Italic ? "italic" : "normal") << "; }\n"; } } 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-font-prewarm { position:absolute; left:-10000px; top:-10000px; opacity:0; }\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")) << ';'; if (const auto atlas = std::find_if(resources.AtlasEntries.begin(), resources.AtlasEntries.end(), [&](const MetaCoreUiCompileResources::AtlasEntry& entry) { return node.Type == MetaCoreUiNodeType::Image && entry.AssetGuid == node.Style.ImageAssetGuid; }); atlas != resources.AtlasEntries.end()) css << " decorator:image(" << atlas->AssetGuid.ToString() << ");"; css << " }\n"; for (const MetaCoreUiAnimationDocument& animation : node.Animations) { const std::string animationName = "mcui-" + node.Id + '-' + animation.Id; css << "@keyframes " << animationName << " { from { " << AnimationPropertyName(animation.Property) << ':' << AnimationValue(animation.Property, animation.From) << "; } to { " << AnimationPropertyName(animation.Property) << ':' << AnimationValue(animation.Property, animation.To) << "; } }\n"; std::string selector = "#" + node.Id; switch (animation.Trigger) { case MetaCoreUiAnimationTrigger::Show: selector += ":not([hidden])"; break; case MetaCoreUiAnimationTrigger::Hide: selector += "[hidden]"; break; case MetaCoreUiAnimationTrigger::Hover: selector += ":hover"; break; case MetaCoreUiAnimationTrigger::Pressed: selector += ":active"; break; case MetaCoreUiAnimationTrigger::Focus: selector += ":focus"; break; case MetaCoreUiAnimationTrigger::Custom: selector += ".animate-" + animation.Id; break; } css << selector << " { animation:" << animationName << ' ' << animation.DurationSeconds << "s " << EasingName(animation.Easing) << ' ' << animation.DelaySeconds << "s both; }\n"; } } if (resources.Theme.has_value()) { const auto& theme = *resources.Theme; for (const auto& style : theme.Styles) { if (style.Token.empty()) continue; css << "body[data-metacore-theme=\"" << Escape(theme.Name) << "\"] .theme-" << style.Token << ThemeStateSelector(style.State) << " { background-color:" << Color(style.BackgroundColor, style.Opacity) << "; color:" << Color(style.TextColor) << "; border-radius:" << style.BorderRadius << "px; font-size:" << style.FontSize << "px; }\n"; } } if (resources.FontFamily.has_value()) { css << "body { font-family:\"" << Escape(resources.FontFamily->FamilyName) << "\""; for (const std::string& fallback : resources.FontFamily->FallbackFamilies) css << ",\"" << Escape(fallback) << "\""; css << "; }\n"; } if (resources.Localization.has_value()) { std::vector locales; for (const auto& locale : resources.Localization->Locales) locales.push_back(&locale); std::sort(locales.begin(), locales.end(), [](const auto* lhs, const auto* rhs) { return lhs->Locale < rhs->Locale; }); std::ostringstream localization; localization << "{\"default\":\"" << EscapeJson(resources.Localization->DefaultLocale) << "\",\"fallback\":\"" << EscapeJson(resources.Localization->FallbackLocale) << "\",\"locales\":{"; bool firstLocale = true; for (const auto* locale : locales) { if (!firstLocale) localization << ','; firstLocale = false; localization << '"' << EscapeJson(locale->Locale) << "\":{"; std::vector entries; for (const auto& entry : locale->Entries) entries.push_back(&entry); std::sort(entries.begin(), entries.end(), [](const auto* lhs, const auto* rhs) { return lhs->Key < rhs->Key; }); bool firstEntry = true; for (const auto* entry : entries) { if (!firstEntry) localization << ','; firstEntry = false; localization << '"' << EscapeJson(entry->Key) << "\":\"" << EscapeJson(entry->Value) << '"'; } localization << '}'; } localization << "}}"; result.LocalizationJson = localization.str(); } result.Rml = rml.str(); result.Rcss = css.str(); result.Succeeded = true; return result; } MetaCoreUiCanvasLayout MetaCoreComputeUiCanvasLayout( const MetaCoreUiDocument& document, const MetaCoreUiCanvasEnvironment& environment ) { MetaCoreUiCanvasLayout result; const float fullWidth = std::max(1.0F, environment.Width); const float fullHeight = std::max(1.0F, environment.Height); result.ViewportLeft = document.UseSafeArea ? std::max(0.0F, environment.SafeInsetLeft) : 0.0F; result.ViewportTop = document.UseSafeArea ? std::max(0.0F, environment.SafeInsetTop) : 0.0F; result.ViewportWidth = std::max(1.0F, fullWidth - result.ViewportLeft - (document.UseSafeArea ? std::max(0.0F, environment.SafeInsetRight) : 0.0F)); result.ViewportHeight = std::max(1.0F, fullHeight - result.ViewportTop - (document.UseSafeArea ? std::max(0.0F, environment.SafeInsetBottom) : 0.0F)); float aspect = result.ViewportWidth / result.ViewportHeight; if (document.MinAspectRatio > 0.0F && aspect < document.MinAspectRatio) { const float constrainedHeight = result.ViewportWidth / document.MinAspectRatio; result.ViewportTop += (result.ViewportHeight - constrainedHeight) * 0.5F; result.ViewportHeight = constrainedHeight; } else if (document.MaxAspectRatio > 0.0F && aspect > document.MaxAspectRatio) { const float constrainedWidth = result.ViewportHeight * document.MaxAspectRatio; result.ViewportLeft += (result.ViewportWidth - constrainedWidth) * 0.5F; result.ViewportWidth = constrainedWidth; } switch (document.ScaleMode) { case MetaCoreUiCanvasScaleMode::ConstantPixelSize: result.Scale = 1.0F; break; case MetaCoreUiCanvasScaleMode::ScaleWithScreenSize: { const float widthScale = result.ViewportWidth / static_cast(std::max(1, document.ReferenceWidth)); const float heightScale = result.ViewportHeight / static_cast(std::max(1, document.ReferenceHeight)); const float match = std::clamp(document.MatchWidthOrHeight, 0.0F, 1.0F); result.Scale = std::exp2(std::log2(std::max(widthScale, 0.0001F)) * (1.0F - match) + std::log2(std::max(heightScale, 0.0001F)) * match); break; } case MetaCoreUiCanvasScaleMode::ConstantPhysicalSize: result.Scale = std::max(1.0F, environment.Dpi) / std::max(1.0F, document.ReferenceDpi); break; } result.Scale = std::clamp(result.Scale, std::max(0.0001F, document.MinScale), std::max(document.MinScale, document.MaxScale)); result.LogicalWidth = result.ViewportWidth / result.Scale; result.LogicalHeight = result.ViewportHeight / result.Scale; return result; } std::optional MetaCoreResolveUiLocalizedText( const MetaCoreUiLocalizationTableDocument& table, std::string_view key, std::string_view locale, std::string_view explicitFallbackLocale ) { const auto lookup = [&](std::string_view requested) -> std::optional { if (requested.empty()) return std::nullopt; const auto foundLocale = std::find_if(table.Locales.begin(), table.Locales.end(), [&](const auto& value) { return value.Locale == requested; }); if (foundLocale == table.Locales.end()) return std::nullopt; const auto foundEntry = std::find_if(foundLocale->Entries.begin(), foundLocale->Entries.end(), [&](const auto& value) { return value.Key == key; }); return foundEntry == foundLocale->Entries.end() ? std::nullopt : std::optional(foundEntry->Value); }; std::vector chain{locale, explicitFallbackLocale, table.FallbackLocale, table.DefaultLocale}; for (const std::string_view candidate : chain) if (const auto value = lookup(candidate); value.has_value()) return value; return std::nullopt; } MetaCoreUiWorldSurfaceLayout MetaCoreComputeUiWorldSurfaceLayout( glm::vec3 worldSize, float pixelsPerUnit, std::int32_t maximumTextureSize ) { MetaCoreUiWorldSurfaceLayout result; result.PixelsPerUnit = std::max(1.0F, pixelsPerUnit); const auto maximum = std::max(1, maximumTextureSize); result.TargetWidth = static_cast(std::clamp( std::round(std::max(0.001F, worldSize.x) * result.PixelsPerUnit), 1.0F, static_cast(maximum))); result.TargetHeight = static_cast(std::clamp( std::round(std::max(0.001F, worldSize.y) * result.PixelsPerUnit), 1.0F, static_cast(maximum))); return result; } std::optional MetaCoreMapWorldUiHitToUv( const glm::mat4& worldMatrix, glm::vec3 worldSize, glm::vec3 worldHitPosition ) { if (worldSize.x <= 0.0F || worldSize.y <= 0.0F) return std::nullopt; const glm::vec4 local = glm::inverse(worldMatrix) * glm::vec4(worldHitPosition, 1.0F); const glm::vec2 uv{ local.x / worldSize.x + 0.5F, 0.5F - local.y / worldSize.y }; constexpr float tolerance = 0.0001F; if (uv.x < -tolerance || uv.y < -tolerance || uv.x > 1.0F + tolerance || uv.y > 1.0F + tolerance) return std::nullopt; return glm::clamp(uv, glm::vec2(0.0F), glm::vec2(1.0F)); } } // namespace MetaCore