diff --git a/Resources/Python/APITest/Test_LolHome_UI.py b/Resources/Python/APITest/Test_LolHome_UI.py new file mode 100644 index 0000000..189b146 --- /dev/null +++ b/Resources/Python/APITest/Test_LolHome_UI.py @@ -0,0 +1,257 @@ +"""Test: LoL-style launcher home UI via UmgMcp socket bridge.""" +import json +import socket +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from Bridge.UmgLayoutCompiler import UmgLayoutCompiler + +HOST, PORT = "127.0.0.1", 55557 +ASSET = "/Game/UI/Test/WBP_LolHomeTest" + +# LoL client palette +C = { + "AppBG": {"R": 0.04, "G": 0.05, "B": 0.08, "A": 1.0}, + "PanelBG": {"R": 0.06, "G": 0.07, "B": 0.10, "A": 0.92}, + "HeaderBG": {"R": 0.08, "G": 0.09, "B": 0.12, "A": 0.95}, + "Gold": {"R": 0.88, "G": 0.72, "B": 0.38, "A": 1.0}, + "Teal": {"R": 0.08, "G": 0.72, "B": 0.62, "A": 1.0}, + "PlayBtn": {"R": 0.05, "G": 0.45, "B": 0.55, "A": 1.0}, + "TextMain": {"R": 0.92, "G": 0.90, "B": 0.85, "A": 1.0}, + "TextDim": {"R": 0.55, "G": 0.55, "B": 0.55, "A": 1.0}, + "CTA": {"R": 0.12, "G": 0.78, "B": 0.45, "A": 1.0}, + "BannerBG": {"R": 0.10, "G": 0.18, "B": 0.14, "A": 1.0}, + "SidebarActive": {"R": 0.12, "G": 0.14, "B": 0.18, "A": 1.0}, +} + + +def send(cmd: str, params: dict | None = None) -> dict: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(60) + s.connect((HOST, PORT)) + payload = json.dumps({"command": cmd, "params": params or {}}).encode("utf-8") + b"\0" + s.sendall(payload) + s.shutdown(socket.SHUT_WR) + data = b"" + while True: + chunk = s.recv(65536) + if not chunk: + break + data += chunk + if b"\0" in data: + break + s.close() + return json.loads(data.split(b"\0")[0].decode("utf-8")) + + +def text_block(name: str, text: str, size: int = 12, color=None, bold: bool = False) -> dict: + color = color or C["TextMain"] + return { + "type": "TextBlock", + "name": name, + "properties": { + "Text": text, + "Font": {"Size": size, "Typeface": "Bold" if bold else "Regular"}, + "ColorAndOpacity": {"SpecifiedColor": color}, + }, + } + + +def border_panel(name: str, bg, padding=(12, 8), child=None, flex: float = 0) -> dict: + node = { + "type": "Border", + "name": name, + "properties": { + "BrushColor": bg, + "Padding": {"Left": padding[0], "Right": padding[0], "Top": padding[1], "Bottom": padding[1]}, + }, + } + if flex: + node["flex"] = flex + if child: + node["children"] = [child] + return node + + +def sidebar_item(name: str, label: str, active: bool = False) -> dict: + bg = C["SidebarActive"] if active else {"R": 0, "G": 0, "B": 0, "A": 0} + return border_panel( + name, + bg, + padding=(16, 10), + child=text_block(f"{name}_Txt", label, 11, C["TextMain"] if active else C["TextDim"], bold=active), + ) + + +def build_dsl() -> dict: + top_bar = { + "layout_type": "flex", + "direction": "row", + "name": "TopBar", + "align": "center", + "properties": {"BrushColor": C["HeaderBG"]}, + "children": [ + border_panel( + "BtnPlay", + C["PlayBtn"], + padding=(28, 10), + child=text_block("TxtPlay", "PLAY", 16, C["TextMain"], bold=True), + ), + border_panel("TabHome", C["SidebarActive"], padding=(14, 6), child=text_block("TxtHome", "主页", 11, C["TextMain"], bold=True)), + border_panel("TabTFT", {"R": 0, "G": 0, "B": 0, "A": 0}, padding=(14, 6), child=text_block("TxtTFT", "云顶之弈", 11, C["TextDim"])), + {"type": "Spacer", "name": "TopSpacer", "flex": 1}, + text_block("TxtCollection", "藏品", 10, C["TextDim"]), + text_block("TxtLoot", "战利品/圣堂", 10, C["TextDim"]), + text_block("TxtStore", "商城", 10, C["TextDim"]), + border_panel("CurrencyRP", {"R": 0.1, "G": 0.1, "B": 0.12, "A": 1}, padding=(8, 4), child=text_block("TxtRP", "50", 10, C["Gold"])), + border_panel("CurrencyBE", {"R": 0.1, "G": 0.1, "B": 0.12, "A": 1}, padding=(8, 4), child=text_block("TxtBE", "21.8万", 10, C["Teal"])), + ], + } + + sidebar = { + "layout_type": "flex", + "direction": "column", + "name": "Sidebar", + "gap": 2, + "properties": {"BrushColor": C["PanelBG"]}, + "children": [ + sidebar_item("NavHome", "首页", active=True), + sidebar_item("NavSeason", "海斗出招季"), + sidebar_item("NavSummon", "召唤中心"), + sidebar_item("NavEvent", "活动中心"), + sidebar_item("NavVex", "7周年小小薇古丝"), + sidebar_item("NavChef", "大厨奥义宝典"), + sidebar_item("NavDragon", "银龙征程"), + {"type": "Spacer", "name": "SidebarSpacer", "flex": 1}, + text_block("TxtGuide", "攻略 | 更多", 9, C["TextDim"]), + text_block("TxtVersion", "版本公告", 9, C["TextDim"]), + ], + } + + banner = { + "layout_type": "absolute", + "name": "BannerArea", + "properties": {"BrushColor": C["BannerBG"]}, + "children": [ + { + "type": "VerticalBox", + "name": "BannerContent", + "position": "center", + "children": [ + text_block("TxtPromo1", "首次6元100%解锁1款未拥有皮肤", 12, C["TextMain"]), + text_block("TxtTitle", "华彩秘宝 · 召唤", 36, C["Gold"], bold=True), + text_block("TxtPromo2", "每10次开启额外解锁一款随机臻彩", 12, C["TextDim"]), + border_panel( + "BtnGo", + C["CTA"], + padding=(48, 12), + child=text_block("TxtGo", "立即前往", 14, C["TextMain"], bold=True), + ), + ], + }, + { + "type": "Border", + "name": "ArrowLeft", + "position": "left-center", + "properties": { + "BrushColor": {"R": 0, "G": 0, "B": 0, "A": 0.5}, + "Padding": {"Left": 8, "Right": 8, "Top": 16, "Bottom": 16}, + }, + "children": [text_block("TxtArrowL", "<", 18, C["TextMain"])], + }, + { + "type": "Border", + "name": "ArrowRight", + "position": "right-center", + "properties": { + "BrushColor": {"R": 0, "G": 0, "B": 0, "A": 0.5}, + "Padding": {"Left": 8, "Right": 8, "Top": 16, "Bottom": 16}, + }, + "children": [text_block("TxtArrowR", ">", 18, C["TextMain"])], + }, + { + "type": "Border", + "name": "FloatTab", + "position": "right-center", + "properties": { + "BrushColor": {"R": 0.08, "G": 0.10, "B": 0.14, "A": 0.9}, + "Padding": {"Left": 6, "Right": 6, "Top": 20, "Bottom": 20}, + }, + "children": [text_block("TxtFloat", "幸运之门\n召唤", 9, C["Gold"])], + }, + ], + } + + main_row = { + "layout_type": "flex", + "direction": "row", + "name": "MainRow", + "align": "stretch", + "children": [ + {**sidebar, "flex": 0, "properties": {**sidebar.get("properties", {}), "Slot": {"Size": {"SizeRule": "Auto"}}}}, + {**banner, "flex": 1}, + ], + } + + root = { + "layout_type": "flex", + "direction": "column", + "name": "Root", + "properties": {"BrushColor": C["AppBG"]}, + "children": [ + top_bar, + {**main_row, "flex": 1}, + ], + } + return root + + +def main() -> None: + print("=== UmgMcp LoL Home UI Test ===") + + r = send("set_target_umg_asset", {"asset_path": ASSET}) + print(f"[1] set_target: {r.get('status')} action={r.get('action', 'existing')}") + + dsl = build_dsl() + compiled, lint = UmgLayoutCompiler.compile(dsl) + print(f"[2] compile: ok={lint.ok}, warnings={len(lint.warnings)}, errors={len(lint.errors)}") + if not lint.ok: + print(json.dumps(lint.to_dict(), ensure_ascii=False, indent=2)) + sys.exit(1) + + r = send( + "apply_json_to_umg", + { + "asset_path": ASSET, + "json_data": json.dumps(compiled, ensure_ascii=False), + "widget_name": "Root", + }, + ) + print(f"[3] apply: status={r.get('status')} success={r.get('success')}") + if r.get("status") == "error" or r.get("success") is False: + print(json.dumps(r, ensure_ascii=False, indent=2)[:3000]) + sys.exit(1) + + r = send("save_asset", {"asset_path": ASSET}) + print(f"[4] save: {r.get('status')}") + + r = send("get_widget_tree", {}) + tree = r.get("widget_tree", "") + lines = [ln for ln in tree.split("\n") if ln.strip()] + print(f"[5] widget_tree ({len(lines)} lines):") + for ln in lines[:30]: + print(f" {ln}") + if len(lines) > 30: + print(f" ... +{len(lines) - 30} more") + + r = send("lint_umg_asset", {"asset_path": ASSET}) + summary = r.get("summary") or r.get("lint_summary") or {} + print(f"[6] lint: {summary if summary else r.get('status')}") + + print("\nDone. Open WBP_LolHomeTest in UE Designer to preview.") + + +if __name__ == "__main__": + main() diff --git a/Resources/Python/Bridge/UmgAssetLinter.py b/Resources/Python/Bridge/UmgAssetLinter.py new file mode 100644 index 0000000..fe87656 --- /dev/null +++ b/Resources/Python/Bridge/UmgAssetLinter.py @@ -0,0 +1,238 @@ +"""Normalize and merge ESLint-style UMG lint reports.""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple + + +def issue_fingerprint(issue: Dict[str, Any]) -> Tuple[str, str, str]: + return ( + str(issue.get("ruleId", "")), + str(issue.get("widgetPath", "")), + str(issue.get("message", "")), + ) + + +def _issue_key(issue: Dict[str, Any]) -> tuple: + return ( + issue.get("ruleId", ""), + issue.get("widgetPath", ""), + issue.get("message", ""), + tuple(issue.get("relatedWidgets") or []), + ) + + +def summarize_issues(issues: Iterable[Dict[str, Any]]) -> Dict[str, int]: + summary = {"error": 0, "warning": 0, "info": 0} + for issue in issues: + severity = str(issue.get("severity", "info")).lower() + if severity not in summary: + severity = "info" + summary[severity] += 1 + return summary + + +def normalize_report(raw: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Ensure a lint report has summary/ok/issues fields.""" + if not raw: + return { + "success": False, + "ok": False, + "geometryTrusted": False, + "summary": {"error": 0, "warning": 0, "info": 0}, + "issues": [], + "error": "Empty lint response.", + } + + if raw.get("success") is False and not raw.get("issues"): + return { + "success": False, + "ok": False, + "geometryTrusted": raw.get("geometryTrusted", False), + "summary": {"error": 1, "warning": 0, "info": 0}, + "issues": [], + "error": raw.get("error", "Lint failed."), + **{k: v for k, v in raw.items() if k not in {"ok", "summary", "issues"}}, + } + + issues = list(raw.get("issues") or []) + summary = raw.get("summary") or summarize_issues(issues) + geometry_trusted = bool(raw.get("geometryTrusted", True)) + ok = raw.get("ok") + if ok is None: + ok = summary.get("error", 0) == 0 and geometry_trusted + + normalized = dict(raw) + normalized["issues"] = issues + normalized["summary"] = summary + normalized["geometryTrusted"] = geometry_trusted + normalized["ok"] = ok + if "success" not in normalized: + normalized["success"] = True + return normalized + + +def merge_reports(*reports: Dict[str, Any]) -> Dict[str, Any]: + """Merge multiple lint reports, deduplicating issues.""" + merged_issues: List[Dict[str, Any]] = [] + seen = set() + asset_path = None + success = True + geometry_trusted = True + errors: List[str] = [] + + for report in reports: + normalized = normalize_report(report) + if normalized.get("success") is False: + success = False + if normalized.get("error"): + errors.append(str(normalized["error"])) + if not asset_path and normalized.get("assetPath"): + asset_path = normalized["assetPath"] + geometry_trusted = geometry_trusted and bool(normalized.get("geometryTrusted", True)) + + for issue in normalized.get("issues", []): + key = _issue_key(issue) + if key in seen: + continue + seen.add(key) + merged_issues.append(issue) + + summary = summarize_issues(merged_issues) + result: Dict[str, Any] = { + "success": success, + "ok": summary["error"] == 0 and geometry_trusted, + "geometryTrusted": geometry_trusted, + "summary": summary, + "issues": merged_issues, + } + if asset_path: + result["assetPath"] = asset_path + if errors: + result["error"] = "; ".join(errors) + return result + + +def is_auto_applicable_fix(fix_suggestion: Optional[Dict[str, Any]]) -> bool: + if not fix_suggestion: + return False + if fix_suggestion.get("action") != "set_widget_properties": + return False + meta = fix_suggestion.get("meta") or {} + return bool(meta.get("autoApplicable")) + + +def detect_stalled(previous: Dict[str, Any], current: Dict[str, Any]) -> bool: + prev_fps: Set[Tuple[str, str, str]] = { + issue_fingerprint(issue) for issue in previous.get("issues", []) + } + curr_fps: Set[Tuple[str, str, str]] = { + issue_fingerprint(issue) for issue in current.get("issues", []) + } + return bool(prev_fps) and prev_fps == curr_fps + + +def collect_error_issues(report: Dict[str, Any]) -> List[Dict[str, Any]]: + return [ + issue + for issue in report.get("issues", []) + if str(issue.get("severity", "")).lower() == "error" + ] + + +async def auto_fix_lint_loop( + lint_fn: Callable[[], Any], + apply_fix_fn: Callable[[Dict[str, Any]], Any], + *, + max_iterations: int = 3, +) -> Dict[str, Any]: + """Bounded lint/fix loop with stall detection.""" + history: List[Dict[str, Any]] = [] + previous_report: Optional[Dict[str, Any]] = None + + for iteration in range(1, max_iterations + 1): + raw = await lint_fn() + report = normalize_report(raw) + history.append( + { + "iteration": iteration, + "phase": "lint", + "report": report, + } + ) + + if report.get("ok"): + return { + "status": "ok", + "iterations": iteration, + "history": history, + "report": report, + } + + if not report.get("geometryTrusted", True): + return { + "status": "blocked", + "reason": "geometry_not_trusted", + "iterations": iteration, + "history": history, + "report": report, + } + + if previous_report and detect_stalled(previous_report, report): + return { + "status": "stalled", + "reason": "unchanged_issues", + "iterations": iteration, + "history": history, + "report": report, + } + + fixable = next( + ( + issue + for issue in collect_error_issues(report) + if is_auto_applicable_fix(issue.get("fixSuggestion")) + ), + None, + ) + if not fixable: + return { + "status": "manual_required", + "reason": "no_auto_fixable_errors", + "iterations": iteration, + "history": history, + "report": report, + } + + fix = fixable["fixSuggestion"] + params = fix.get("params") or {} + apply_result = await apply_fix_fn(params) + history.append( + { + "iteration": iteration, + "phase": "apply_fix", + "issue": issue_fingerprint(fixable), + "result": apply_result, + } + ) + + if apply_result.get("success") is False: + return { + "status": "apply_failed", + "iterations": iteration, + "history": history, + "report": report, + "apply_result": apply_result, + } + + previous_report = report + + final_raw = await lint_fn() + final_report = normalize_report(final_raw) + history.append({"iteration": max_iterations, "phase": "lint", "report": final_report}) + return { + "status": "max_iterations_reached", + "iterations": max_iterations, + "history": history, + "report": final_report, + } diff --git a/Resources/Python/Bridge/UmgLayoutCompiler.py b/Resources/Python/Bridge/UmgLayoutCompiler.py new file mode 100644 index 0000000..f16102f --- /dev/null +++ b/Resources/Python/Bridge/UmgLayoutCompiler.py @@ -0,0 +1,878 @@ +""" +UmgLayoutCompiler — CSS-like Layout DSL → UMG apply_layout JSON + +Compiles high-level flex/grid/absolute layout constraints into the verbose +widget tree JSON consumed by UpsertWidgetFromJsonAppendOnly. + +Unified layout semantics (AI-facing, no raw Slot JSON): + size: "fill" | "auto" | "100%" → Box Fill/Auto or Canvas stretch + fill_weight / flex_grow → Fill weight (default 1) + h_align / v_align → Left|Center|Right|Fill / Top|Center|Bottom|Fill + align_items / justify_content → container cross/main axis (flex) + width / height: "100%" → size fill on box children + position → canvas preset (absolute containers only) + +Patch mode (partial screen updates): + {"patch": true, "updates": [{"name": "WidgetA", "size": "fill", ...}]} + or single widget: {"patch": true, "name": "WidgetA", "size": "fill"} +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Dict, List, Optional, Set, Tuple + +logger = logging.getLogger("UmgLayoutCompiler") + +LAYOUT_DSL_VERSION = "2025-06-20-unified-semantics-v1" + +# Widgets that accept exactly one child in UMG +SINGLE_CHILD_PANELS = frozenset({ + "Button", "Border", "SizeBox", "ScaleBox", + "RetainerBox", "InvalidationBox", "CheckBox", +}) + +# Panels that accept multiple children +MULTI_CHILD_PANELS = frozenset({ + "CanvasPanel", "VerticalBox", "HorizontalBox", "GridPanel", + "Overlay", "WrapBox", "ScrollBox", "UniformGridPanel", + "WidgetSwitcher", "SafeZone", +}) + +SLOT_SCHEMA_BY_PARENT: Dict[str, frozenset] = { + "VerticalBox": frozenset({"Size", "Padding", "HorizontalAlignment", "VerticalAlignment"}), + "HorizontalBox": frozenset({"Size", "Padding", "HorizontalAlignment", "VerticalAlignment"}), + "CanvasPanel": frozenset({"LayoutData", "Alignment", "AutoSize", "ZOrder"}), + "GridPanel": frozenset({"Row", "Column", "RowSpan", "ColumnSpan", "HorizontalAlignment", "VerticalAlignment", "Padding"}), + "Overlay": frozenset({"HorizontalAlignment", "VerticalAlignment", "Padding"}), + "ScrollBox": frozenset({"Padding", "HorizontalAlignment", "VerticalAlignment"}), + "WrapBox": frozenset({"Padding", "FillEmptySpace", "HorizontalAlignment", "VerticalAlignment"}), +} + +CANVAS_ONLY_SLOT_KEYS = frozenset({"Anchors", "LayoutData", "Alignment", "Position", "AutoSize", "ZOrder"}) + +SIZE_SEMANTIC_KEYS = frozenset({"size", "fill_weight", "flex_grow", "flex", "width", "height"}) +ALIGN_SEMANTIC_KEYS = frozenset({"h_align", "v_align", "align_items", "align", "justify_content", "justify"}) +CANVAS_SEMANTIC_KEYS = frozenset({"position", "anchors", "offsets", "margin", "alignment"}) + +WIDGET_CLASS_MAP: Dict[str, str] = { + "Text": "/Script/UMG.TextBlock", + "TextBlock": "/Script/UMG.TextBlock", + "Image": "/Script/UMG.Image", + "Button": "/Script/UMG.Button", + "Border": "/Script/UMG.Border", + "CanvasPanel": "/Script/UMG.CanvasPanel", + "VerticalBox": "/Script/UMG.VerticalBox", + "HorizontalBox": "/Script/UMG.HorizontalBox", + "Overlay": "/Script/UMG.Overlay", + "GridPanel": "/Script/UMG.GridPanel", + "UniformGridPanel": "/Script/UMG.UniformGridPanel", + "WrapBox": "/Script/UMG.WrapBox", + "ScrollBox": "/Script/UMG.ScrollBox", + "SizeBox": "/Script/UMG.SizeBox", + "ScaleBox": "/Script/UMG.ScaleBox", + "Spacer": "/Script/UMG.Spacer", + "ProgressBar": "/Script/UMG.ProgressBar", + "EditableTextBox": "/Script/UMG.EditableTextBox", + "Slider": "/Script/UMG.Slider", + "CheckBox": "/Script/UMG.CheckBox", + "WidgetSwitcher": "/Script/UMG.WidgetSwitcher", + "SafeZone": "/Script/UMG.SafeZone", +} + +# CSS align → UE box-slot alignment (direction-aware) +FLEX_ALIGN_MAP = { + "start": ("Left", "Top"), + "center": ("Center", "Center"), + "end": ("Right", "Bottom"), + "stretch": ("Fill", "Fill"), + "fill": ("Fill", "Fill"), +} + +UE_H_ALIGN = { + "left": "HAlign_Left", "center": "HAlign_Center", "right": "HAlign_Right", + "fill": "HAlign_Fill", "stretch": "HAlign_Fill", "start": "HAlign_Left", "end": "HAlign_Right", +} + +UE_V_ALIGN = { + "top": "VAlign_Top", "center": "VAlign_Center", "bottom": "VAlign_Bottom", + "fill": "VAlign_Fill", "stretch": "VAlign_Fill", "start": "VAlign_Top", "end": "VAlign_Bottom", +} + +# Named absolute positions → (anchor_min, anchor_max, alignment) +POSITION_PRESETS: Dict[str, Tuple[List[float], List[float], List[float]]] = { + "center": ([0.5, 0.5], [0.5, 0.5], [0.5, 0.5]), + "top-left": ([0.0, 0.0], [0.0, 0.0], [0.0, 0.0]), + "top-center": ([0.5, 0.0], [0.5, 0.0], [0.5, 0.0]), + "top-right": ([1.0, 0.0], [1.0, 0.0], [1.0, 0.0]), + "bottom-left": ([0.0, 1.0], [0.0, 1.0], [0.0, 1.0]), + "bottom-center": ([0.5, 1.0], [0.5, 1.0], [0.5, 1.0]), + "bottom-right": ([1.0, 1.0], [1.0, 1.0], [1.0, 1.0]), + "left-center": ([0.0, 0.5], [0.0, 0.5], [0.0, 0.5]), + "right-center": ([1.0, 0.5], [1.0, 0.5], [1.0, 0.5]), + "fill": ([0.0, 0.0], [1.0, 1.0], [0.5, 0.5]), + "stretch": ([0.0, 0.0], [1.0, 1.0], [0.5, 0.5]), +} + +_TREE_LINE_RE = re.compile(r"^(\s*)-?\s*(\w+)\s+\[(\w+)\]\s*$") + + +class LayoutLint: + """Collects lint warnings and blocking errors during compilation.""" + + def __init__(self) -> None: + self.warnings: List[str] = [] + self.errors: List[str] = [] + self.issues: List[Dict[str, Any]] = [] + + def _append_issue( + self, + severity: str, + message: str, + rule_id: str, + widget_path: Optional[str] = None, + fix_suggestion: Optional[Dict[str, Any]] = None, + ) -> None: + issue: Dict[str, Any] = { + "ruleId": rule_id, + "severity": severity, + "message": message, + "widgetPath": widget_path or "", + "source": "compile-time", + } + if fix_suggestion: + issue["fixSuggestion"] = fix_suggestion + self.issues.append(issue) + + def warn( + self, + message: str, + *, + rule_id: str = "layout-warning", + widget_path: Optional[str] = None, + ) -> None: + self.warnings.append(message) + self._append_issue("warning", message, rule_id, widget_path) + logger.warning("Layout lint: %s", message) + + def error( + self, + message: str, + *, + rule_id: str = "layout-error", + widget_path: Optional[str] = None, + ) -> None: + self.errors.append(message) + self._append_issue("error", message, rule_id, widget_path) + logger.error("Layout lint: %s", message) + + @property + def ok(self) -> bool: + return len(self.errors) == 0 + + def to_dict(self) -> Dict[str, Any]: + error_count = sum(1 for i in self.issues if i.get("severity") == "error") + warning_count = sum(1 for i in self.issues if i.get("severity") == "warning") + info_count = sum(1 for i in self.issues if i.get("severity") == "info") + return { + "ok": self.ok, + "issues": self.issues, + "warnings": self.warnings, + "errors": self.errors, + "summary": { + "error": error_count, + "warning": warning_count, + "info": info_count, + }, + } + + +class UmgLayoutCompiler: + """Compiles CSS-like layout DSL nodes into UMG apply_layout JSON.""" + + _counter = 0 + + @classmethod + def is_patch_dsl(cls, dsl_node: Dict[str, Any]) -> bool: + from Bridge.UmgLayoutPatch import is_patch_dsl + + return is_patch_dsl(dsl_node) + + @classmethod + def compile(cls, dsl_node: Dict[str, Any], lint: Optional[LayoutLint] = None) -> Tuple[Dict[str, Any], LayoutLint]: + lint = lint or LayoutLint() + if cls.is_patch_dsl(dsl_node): + lint.error( + "Patch DSL cannot be compiled as a full layout tree. " + "Use apply_constrained_layout with \"patch\": true (or mode/layout_type: \"patch\").", + rule_id="patch-misrouted", + ) + return {}, lint + return cls._compile_node(dsl_node, parent_container=None, lint=lint), lint + + @classmethod + def compile_patch( + cls, + dsl_node: Dict[str, Any], + parent_map: Dict[str, str], + lint: Optional[LayoutLint] = None, + ) -> Tuple[List[Dict[str, Any]], LayoutLint]: + """Compile slot-only patches for existing widgets (by name).""" + lint = lint or LayoutLint() + updates = cls._normalize_patch_updates(dsl_node, lint) + patches: List[Dict[str, Any]] = [] + + for update in updates: + name = update.get("name") or update.get("widget_name") + if not name: + lint.error("Patch entry missing 'name' or 'widget_name'.", rule_id="patch-missing-name") + continue + + parent = parent_map.get(str(name)) + if not parent: + lint.warn( + f"Widget '{name}': parent type unknown; assuming VerticalBox slot semantics.", + rule_id="patch-unknown-parent", + widget_path=str(name), + ) + parent = "VerticalBox" + + slot = cls.compile_slot_for_widget(update, parent, lint, widget_name=str(name), partial=True) + if slot: + patches.append({"widget_name": str(name), "parent_container": parent, "slot": slot}) + + return patches, lint + + @classmethod + def parse_widget_tree_parents(cls, tree_text: str) -> Dict[str, str]: + """Parse get_widget_tree text → {child_name: parent_panel_class}.""" + parent_map: Dict[str, str] = {} + stack: List[Tuple[int, str, str]] = [] + + for line in tree_text.splitlines(): + if "[WidgetBlueprint]" in line: + continue + match = _TREE_LINE_RE.match(line.rstrip()) + if not match: + continue + + indent, name, widget_class = match.group(1), match.group(2), match.group(3) + depth = len(indent) // 2 + if depth == 0 and not indent: + depth = 1 + + while stack and stack[-1][0] >= depth: + stack.pop() + + if stack: + parent_map[name] = stack[-1][2] + + stack.append((depth, name, widget_class)) + + return parent_map + + @classmethod + def _normalize_patch_updates(cls, dsl_node: Dict[str, Any], lint: LayoutLint) -> List[Dict[str, Any]]: + raw_updates = dsl_node.get("updates") + if isinstance(raw_updates, list) and raw_updates: + return [u for u in raw_updates if isinstance(u, dict)] + + single = {k: v for k, v in dsl_node.items() if k not in ("patch", "updates", "mode")} + if single.get("name") or single.get("widget_name"): + return [single] + + lint.error("Patch DSL requires 'updates' array or a single 'name' entry.", rule_id="patch-empty") + return [] + + @classmethod + def _next_name(cls, prefix: str) -> str: + cls._counter += 1 + return f"{prefix}_{cls._counter}" + + @classmethod + def _resolve_widget_class(cls, node: Dict[str, Any]) -> Tuple[str, str]: + """Returns (short_name, full_class_path).""" + if "widget_class" in node: + full = node["widget_class"] + short = full.rsplit(".", 1)[-1] + return short, full + widget_type = node.get("type") or node.get("widget_type") or "VerticalBox" + full = WIDGET_CLASS_MAP.get(widget_type, f"/Script/UMG.{widget_type}") + short = widget_type + return short, full + + @classmethod + def _to_ue_box_h_align(cls, value: str) -> str: + if value.startswith("HAlign_"): + return value + mapped = UE_H_ALIGN.get(str(value).strip().lower()) + return mapped or f"HAlign_{value}" + + @classmethod + def _to_ue_box_v_align(cls, value: str) -> str: + if value.startswith("VAlign_"): + return value + mapped = UE_V_ALIGN.get(str(value).strip().lower()) + return mapped or f"VAlign_{value}" + + @classmethod + def _parse_ue_align(cls, value: Any, axis: str) -> Optional[str]: + if value is None: + return None + key = str(value).strip().lower().replace("_", "-") + mapping = UE_H_ALIGN if axis == "h" else UE_V_ALIGN + return mapping.get(key) + + @classmethod + def _resolve_size_rule(cls, dsl_node: Dict[str, Any]) -> Tuple[bool, float]: + """Returns (is_fill, fill_weight).""" + size = dsl_node.get("size") + if isinstance(size, str): + normalized = size.strip().lower() + if normalized in ("fill", "100%", "stretch"): + weight = float( + dsl_node.get("fill_weight", dsl_node.get("flex_grow", dsl_node.get("flex", 1))) or 1 + ) + return True, weight + if normalized in ("auto", "content", "fit-content"): + return False, 1.0 + + for key in ("width", "height"): + dim = dsl_node.get(key) + if isinstance(dim, str) and dim.strip().lower() in ("100%", "fill", "stretch"): + weight = float( + dsl_node.get("fill_weight", dsl_node.get("flex_grow", dsl_node.get("flex", 1))) or 1 + ) + return True, weight + + flex_grow = dsl_node.get("flex_grow", dsl_node.get("flex")) + if flex_grow is not None: + grow = float(flex_grow) + return grow > 0, grow if grow > 0 else 1.0 + + return False, 1.0 + + @classmethod + def _semantic_keys_present(cls, dsl_node: Dict[str, Any], keys: Set[str]) -> bool: + return any(k in dsl_node for k in keys) + + @classmethod + def _align_to_canvas_position(cls, h_ue: str, v_ue: str) -> str: + if h_ue == "Fill" or v_ue == "Fill": + return "fill" + + h_part = {"Left": "left", "Center": "center", "Right": "right"}.get(h_ue, "left") + v_part = {"Top": "top", "Center": "center", "Bottom": "bottom"}.get(v_ue, "top") + + if h_part == "center" and v_part == "center": + return "center" + if v_part == "center": + return f"{h_part}-center" + if h_part == "center": + return f"{v_part}-center" + return f"{v_part}-{h_part}" + + @classmethod + def compile_slot_for_widget( + cls, + dsl_node: Dict[str, Any], + parent_container: str, + lint: LayoutLint, + *, + widget_name: Optional[str] = None, + partial: bool = False, + container_align: str = "stretch", + container_justify: str = "stretch", + is_column: bool = True, + ) -> Dict[str, Any]: + """Compile unified layout semantics to Slot dict for a given parent container type.""" + name = widget_name or dsl_node.get("name") or dsl_node.get("widget_name") or "?" + + if parent_container in ("VerticalBox", "HorizontalBox"): + return cls._compile_box_slot_semantics( + dsl_node, + parent_container, + lint, + name, + container_align=container_align, + container_justify=container_justify, + is_column=(parent_container == "VerticalBox") if parent_container else is_column, + partial=partial, + ) + + if parent_container == "CanvasPanel": + if partial and not cls._semantic_keys_present(dsl_node, SIZE_SEMANTIC_KEYS | ALIGN_SEMANTIC_KEYS | CANVAS_SEMANTIC_KEYS): + return {} + return cls._compile_canvas_slot(dsl_node, lint) + + if parent_container == "GridPanel": + slot: Dict[str, Any] = {} + if not partial or cls._semantic_keys_present(dsl_node, ALIGN_SEMANTIC_KEYS): + h = cls._parse_ue_align(dsl_node.get("h_align"), "h") + v = cls._parse_ue_align(dsl_node.get("v_align"), "v") + if h: + slot["HorizontalAlignment"] = h + if v: + slot["VerticalAlignment"] = v + return cls._strip_incompatible_slot_keys(slot, parent_container, lint, name) + + if parent_container in ("Overlay", "ScrollBox", "WrapBox"): + slot = {} + h = cls._parse_ue_align(dsl_node.get("h_align"), "h") + v = cls._parse_ue_align(dsl_node.get("v_align"), "v") + if h: + slot["HorizontalAlignment"] = h + if v: + slot["VerticalAlignment"] = v + return cls._strip_incompatible_slot_keys(slot, parent_container, lint, name) + + return cls._compile_box_slot_semantics( + dsl_node, "VerticalBox", lint, name, partial=partial, + ) + + @classmethod + def _compile_box_slot_semantics( + cls, + dsl_node: Dict[str, Any], + parent_container: str, + lint: LayoutLint, + widget_name: str, + *, + container_align: str = "stretch", + container_justify: str = "stretch", + is_column: bool = True, + partial: bool = False, + ) -> Dict[str, Any]: + slot: Dict[str, Any] = {} + + has_size = cls._semantic_keys_present(dsl_node, SIZE_SEMANTIC_KEYS) + if has_size or not partial: + is_fill, weight = cls._resolve_size_rule(dsl_node) + if not has_size and not partial: + flex_grow = float(dsl_node.get("flex_grow", dsl_node.get("flex", 0)) or 0) + is_fill = flex_grow > 0 + weight = flex_grow if is_fill else 1.0 + slot["Size"] = { + "SizeRule": "Fill" if is_fill else "Automatic", + "Value": weight if is_fill else 1.0, + } + + has_align = cls._semantic_keys_present(dsl_node, ALIGN_SEMANTIC_KEYS) + if has_align or not partial: + h_explicit = cls._parse_ue_align(dsl_node.get("h_align"), "h") + v_explicit = cls._parse_ue_align(dsl_node.get("v_align"), "v") + + align_items = str(dsl_node.get("align_items", dsl_node.get("align", container_align))).lower() + justify_content = str( + dsl_node.get("justify_content", dsl_node.get("justify", container_justify)) + ).lower() + + cross_h, cross_v = FLEX_ALIGN_MAP.get(align_items, ("Fill", "Fill")) + main_h, main_v = FLEX_ALIGN_MAP.get(justify_content, (cross_h, cross_v)) + + if is_column: + slot["HorizontalAlignment"] = h_explicit or cls._to_ue_box_h_align(cross_h) + slot["VerticalAlignment"] = v_explicit or cls._to_ue_box_v_align(main_v) + else: + slot["HorizontalAlignment"] = h_explicit or cls._to_ue_box_h_align(main_h) + slot["VerticalAlignment"] = v_explicit or cls._to_ue_box_v_align(cross_v) + + return cls._strip_incompatible_slot_keys(slot, parent_container, lint, widget_name) + + @classmethod + def _compile_node( + cls, + dsl_node: Dict[str, Any], + parent_container: Optional[str], + lint: LayoutLint, + ) -> Dict[str, Any]: + layout_type = dsl_node.get("layout_type") or dsl_node.get("display") + + if layout_type is None and (dsl_node.get("type") or dsl_node.get("widget_class")): + return cls._compile_leaf(dsl_node, parent_container, lint) + + if layout_type in (None, "flex"): + return cls._compile_flex(dsl_node, parent_container, lint) + if layout_type == "grid": + return cls._compile_grid(dsl_node, parent_container, lint) + if layout_type in ("absolute", "canvas"): + return cls._compile_absolute(dsl_node, parent_container, lint) + + lint.error(f"Unknown layout_type '{layout_type}'. Use flex, grid, or absolute.", rule_id="unknown-layout-type") + return cls._compile_flex(dsl_node, parent_container, lint) + + @classmethod + def _compile_flex(cls, dsl_node: Dict[str, Any], parent_container: Optional[str], lint: LayoutLint) -> Dict[str, Any]: + direction = dsl_node.get("direction", "column") + is_column = direction in ("column", "col", "vertical") + container_short = "VerticalBox" if is_column else "HorizontalBox" + container_class = WIDGET_CLASS_MAP[container_short] + + name = dsl_node.get("name") or dsl_node.get("widget_name") or cls._next_name("FlexBox") + gap = float(dsl_node.get("gap", 0) or 0) + align_items = str(dsl_node.get("align_items", dsl_node.get("align", "stretch"))).lower() + justify_content = str(dsl_node.get("justify_content", dsl_node.get("justify", align_items))).lower() + + children_dsl = cls._normalize_children(dsl_node, container_short, lint) + compiled_children: List[Dict[str, Any]] = [] + + for idx, child_dsl in enumerate(children_dsl): + child_json = cls._compile_child_under_flex( + child_dsl, + container_short, + is_column, + gap, + align_items, + justify_content, + is_last=(idx == len(children_dsl) - 1), + lint=lint, + ) + compiled_children.append(child_json) + + result: Dict[str, Any] = { + "widget_name": name, + "widget_class": container_class, + "children": compiled_children, + } + + widget_props = dict(dsl_node.get("properties") or {}) + parent_slot = cls._compile_parent_slot(dsl_node, parent_container, lint) + if parent_slot: + widget_props["Slot"] = {**widget_props.get("Slot", {}), **parent_slot} + if widget_props: + result["properties"] = widget_props + + return result + + @classmethod + def _compile_grid(cls, dsl_node: Dict[str, Any], parent_container: Optional[str], lint: LayoutLint) -> Dict[str, Any]: + name = dsl_node.get("name") or dsl_node.get("widget_name") or cls._next_name("Grid") + children_dsl = cls._normalize_children(dsl_node, "GridPanel", lint) + compiled_children: List[Dict[str, Any]] = [] + + for child_dsl in children_dsl: + child_json = cls._compile_leaf(child_dsl, "GridPanel", lint) + row = int(child_dsl.get("row", child_dsl.get("grid_row", 0))) + col = int(child_dsl.get("col", child_dsl.get("grid_col", child_dsl.get("column", 0)))) + row_span = int(child_dsl.get("row_span", 1)) + col_span = int(child_dsl.get("col_span", child_dsl.get("column_span", 1))) + + slot = child_json.setdefault("properties", {}).setdefault("Slot", {}) + slot["Row"] = row + slot["Column"] = col + slot["RowSpan"] = row_span + slot["ColumnSpan"] = col_span + + align_slot = cls.compile_slot_for_widget( + child_dsl, "GridPanel", lint, + widget_name=str(child_json.get("widget_name", "")), + partial=True, + ) + slot.update(align_slot) + + child_json["SlotData"] = slot + compiled_children.append(child_json) + + result: Dict[str, Any] = { + "widget_name": name, + "widget_class": WIDGET_CLASS_MAP["GridPanel"], + "children": compiled_children, + } + widget_props = dict(dsl_node.get("properties") or {}) + parent_slot = cls._compile_parent_slot(dsl_node, parent_container, lint) + if parent_slot: + widget_props["Slot"] = {**widget_props.get("Slot", {}), **parent_slot} + if widget_props: + result["properties"] = widget_props + return result + + @classmethod + def _compile_absolute(cls, dsl_node: Dict[str, Any], parent_container: Optional[str], lint: LayoutLint) -> Dict[str, Any]: + name = dsl_node.get("name") or dsl_node.get("widget_name") or cls._next_name("Canvas") + children_dsl = cls._normalize_children(dsl_node, "CanvasPanel", lint) + compiled_children: List[Dict[str, Any]] = [] + + for child_dsl in children_dsl: + child_json = cls._compile_leaf(child_dsl, "CanvasPanel", lint) + slot = cls._compile_canvas_slot(child_dsl, lint) + child_json.setdefault("properties", {})["Slot"] = slot + child_json["SlotData"] = slot + compiled_children.append(child_json) + + result: Dict[str, Any] = { + "widget_name": name, + "widget_class": WIDGET_CLASS_MAP["CanvasPanel"], + "children": compiled_children, + } + widget_props = dict(dsl_node.get("properties") or {}) + parent_slot = cls._compile_parent_slot(dsl_node, parent_container, lint) + if parent_slot: + widget_props["Slot"] = {**widget_props.get("Slot", {}), **parent_slot} + if widget_props: + result["properties"] = widget_props + return result + + @classmethod + def _compile_leaf(cls, dsl_node: Dict[str, Any], parent_container: Optional[str], lint: LayoutLint) -> Dict[str, Any]: + short, full_class = cls._resolve_widget_class(dsl_node) + name = dsl_node.get("name") or dsl_node.get("widget_name") or cls._next_name(short) + + widget_props = dict(dsl_node.get("properties") or {}) + + if "text" in dsl_node and short in ("Text", "TextBlock"): + widget_props.setdefault("Text", dsl_node["text"]) + if "content" in dsl_node: + widget_props.setdefault("Text", dsl_node["content"]) + + parent_slot = cls._compile_parent_slot(dsl_node, parent_container, lint) + if parent_slot: + widget_props["Slot"] = {**widget_props.get("Slot", {}), **parent_slot} + + result: Dict[str, Any] = { + "widget_name": name, + "widget_class": full_class, + } + if widget_props: + result["properties"] = widget_props + + children_dsl = dsl_node.get("children") + if children_dsl: + normalized = cls._normalize_children(dsl_node, short, lint) + result["children"] = [ + cls._compile_node(child, parent_container=short, lint=lint) + for child in normalized + ] + + if "SlotData" in dsl_node: + slot = dsl_node["SlotData"] + result.setdefault("properties", {})["Slot"] = { + **result.get("properties", {}).get("Slot", {}), + **slot, + } + result["SlotData"] = result["properties"]["Slot"] + + return result + + @classmethod + def _compile_child_under_flex( + cls, + child_dsl: Dict[str, Any], + container_short: str, + is_column: bool, + gap: float, + align_items: str, + justify_content: str, + is_last: bool, + lint: LayoutLint, + ) -> Dict[str, Any]: + child_layout = child_dsl.get("layout_type") or child_dsl.get("display") + if child_layout in ("flex", "grid", "absolute", "canvas"): + child_json = cls._compile_node(child_dsl, parent_container=container_short, lint=lint) + else: + child_json = cls._compile_leaf(child_dsl, parent_container=container_short, lint=lint) + if child_dsl.get("children"): + normalized = cls._normalize_children(child_dsl, cls._resolve_widget_class(child_dsl)[0], lint) + child_json["children"] = [ + cls._compile_node(c, parent_container=cls._resolve_widget_class(child_dsl)[0], lint=lint) + for c in normalized + ] + + if any(k in child_dsl for k in ("anchors", "position", "offsets")) and container_short != "CanvasPanel": + lint.warn( + f"Widget '{child_json.get('widget_name', '?')}': anchors/position ignored under " + f"{container_short} (use layout_type: absolute for canvas positioning).", + rule_id="canvas-misuse", + widget_path=str(child_json.get("widget_name", "")), + ) + + slot = cls._compile_box_slot_semantics( + child_dsl, + container_short, + lint, + str(child_json.get("widget_name", "?")), + container_align=align_items, + container_justify=justify_content, + is_column=is_column, + partial=False, + ) + + if gap > 0 and not is_last: + if is_column: + slot["Padding"] = {**slot.get("Padding", {}), "Bottom": gap} + else: + slot["Padding"] = {**slot.get("Padding", {}), "Right": gap} + + child_json.setdefault("properties", {})["Slot"] = { + **child_json.get("properties", {}).get("Slot", {}), + **slot, + } + child_json["SlotData"] = child_json["properties"]["Slot"] + return child_json + + @classmethod + def _compile_canvas_slot(cls, child_dsl: Dict[str, Any], lint: LayoutLint) -> Dict[str, Any]: + slot: Dict[str, Any] = dict((child_dsl.get("properties") or {}).get("Slot", {})) + slot.update(child_dsl.get("SlotData") or {}) + + size_val = child_dsl.get("size") + position: Optional[str] = None + + if "position" in child_dsl: + position = str(child_dsl["position"]).lower().replace("_", "-") + elif isinstance(size_val, str) and size_val.strip().lower() in ("fill", "100%", "stretch"): + position = "fill" + elif cls._semantic_keys_present(child_dsl, ALIGN_SEMANTIC_KEYS): + h_ue = cls._parse_ue_align(child_dsl.get("h_align"), "h") or "Left" + v_ue = cls._parse_ue_align(child_dsl.get("v_align"), "v") or "Top" + position = cls._align_to_canvas_position(h_ue, v_ue) + else: + position = "top-left" + + if position and position in POSITION_PRESETS: + anchor_min, anchor_max, alignment = POSITION_PRESETS[position] + slot["LayoutData"] = { + "Anchors": {"Minimum": anchor_min, "Maximum": anchor_max}, + "Offsets": slot.get("LayoutData", {}).get("Offsets", {"Left": 0, "Top": 0, "Right": 0, "Bottom": 0}), + } + slot["Alignment"] = alignment + elif "anchors" in child_dsl: + anchors = child_dsl["anchors"] + slot["LayoutData"] = { + "Anchors": { + "Minimum": anchors.get("min", anchors.get("minimum", [0, 0])), + "Maximum": anchors.get("max", anchors.get("maximum", anchors.get("min", [0, 0]))), + }, + } + if "offsets" in child_dsl: + slot["LayoutData"]["Offsets"] = child_dsl["offsets"] + if "alignment" in child_dsl: + slot["Alignment"] = child_dsl["alignment"] + + if "margin" in child_dsl: + m = child_dsl["margin"] + if isinstance(m, (int, float)): + slot.setdefault("LayoutData", {}).setdefault("Offsets", {}) + offsets = slot["LayoutData"]["Offsets"] + offsets.update({"Left": m, "Top": m, "Right": m, "Bottom": m}) + elif isinstance(m, dict): + slot.setdefault("LayoutData", {}).setdefault("Offsets", {}).update(m) + + if isinstance(size_val, (list, tuple)) or (isinstance(size_val, (int, float)) and not isinstance(size_val, bool)): + if isinstance(size_val, (int, float)): + w, h = size_val, size_val + else: + w, h = size_val if len(size_val) >= 2 else (size_val[0], size_val[0]) + layout_data = slot.setdefault("LayoutData", {}) + offsets = dict(layout_data.get("Offsets", {"Left": 0, "Top": 0, "Right": 0, "Bottom": 0})) + offsets["Right"] = w + offsets["Bottom"] = h + layout_data["Offsets"] = offsets + slot.pop("Size", None) + + return cls._strip_incompatible_slot_keys(slot, "CanvasPanel", lint, child_dsl.get("name", "?")) + + @classmethod + def _compile_parent_slot( + cls, + dsl_node: Dict[str, Any], + parent_container: Optional[str], + lint: LayoutLint, + ) -> Dict[str, Any]: + """Compile slot props that belong to this node (owned by its parent slot).""" + slot: Dict[str, Any] = dict((dsl_node.get("properties") or {}).get("Slot", {})) + slot.update(dsl_node.get("SlotData") or {}) + + if not parent_container: + if cls._semantic_keys_present(dsl_node, SIZE_SEMANTIC_KEYS | ALIGN_SEMANTIC_KEYS): + return cls._compile_box_slot_semantics( + dsl_node, "VerticalBox", lint, str(dsl_node.get("name", "?")), partial=True, + ) + return cls._strip_incompatible_slot_keys(slot, "", lint, dsl_node.get("name", "?")) + + if parent_container in ("VerticalBox", "HorizontalBox"): + semantics = cls._compile_box_slot_semantics( + dsl_node, + parent_container, + lint, + str(dsl_node.get("name", "?")), + partial=not cls._semantic_keys_present(dsl_node, SIZE_SEMANTIC_KEYS | ALIGN_SEMANTIC_KEYS), + ) + slot.update(semantics) + return cls._strip_incompatible_slot_keys(slot, parent_container, lint, dsl_node.get("name", "?")) + + if parent_container == "CanvasPanel": + if cls._semantic_keys_present( + dsl_node, SIZE_SEMANTIC_KEYS | ALIGN_SEMANTIC_KEYS | CANVAS_SEMANTIC_KEYS + ): + canvas_slot = cls._compile_canvas_slot(dsl_node, lint) + slot.update(canvas_slot) + return slot + + semantics = cls.compile_slot_for_widget( + dsl_node, parent_container, lint, + widget_name=str(dsl_node.get("name", "?")), + partial=True, + ) + slot.update(semantics) + return cls._strip_incompatible_slot_keys(slot, parent_container, lint, dsl_node.get("name", "?")) + + @classmethod + def _strip_incompatible_slot_keys( + cls, + slot: Dict[str, Any], + parent_container: str, + lint: LayoutLint, + widget_name: str, + ) -> Dict[str, Any]: + if parent_container and parent_container != "CanvasPanel": + stripped = [k for k in CANVAS_ONLY_SLOT_KEYS if k in slot] + if stripped: + slot = {k: v for k, v in slot.items() if k not in CANVAS_ONLY_SLOT_KEYS} + lint.warn( + f"Widget '{widget_name}': discarded canvas-only slot keys {stripped} " + f"(parent is {parent_container}).", + rule_id="slot-incompatible", + widget_path=widget_name, + ) + + allowed = SLOT_SCHEMA_BY_PARENT.get(parent_container) + if allowed: + rejected = [k for k in slot if k not in allowed] + if rejected: + slot = {k: v for k, v in slot.items() if k in allowed} + lint.warn( + f"Widget '{widget_name}': rejected slot keys {rejected} for parent {parent_container}.", + rule_id="slot-incompatible", + widget_path=widget_name, + ) + + return slot + + @classmethod + def _normalize_children( + cls, + dsl_node: Dict[str, Any], + parent_short: str, + lint: LayoutLint, + ) -> List[Dict[str, Any]]: + children: List[Dict[str, Any]] = list(dsl_node.get("children") or []) + if not children: + return children + + if parent_short in SINGLE_CHILD_PANELS and len(children) > 1: + wrapper_name = cls._next_name(f"{dsl_node.get('name', parent_short)}_Wrap") + lint.warn( + f"Parent '{parent_short}' accepts one child; wrapping {len(children)} children " + f"in VerticalBox '{wrapper_name}'.", + rule_id="single-child-wrap", + widget_path=str(dsl_node.get("name", parent_short)), + ) + return [{ + "layout_type": "flex", + "direction": "column", + "name": wrapper_name, + "children": children, + }] + + return children diff --git a/Resources/Python/Bridge/UmgLayoutPatch.py b/Resources/Python/Bridge/UmgLayoutPatch.py new file mode 100644 index 0000000..69d0777 --- /dev/null +++ b/Resources/Python/Bridge/UmgLayoutPatch.py @@ -0,0 +1,96 @@ +"""Patch-mode orchestration for apply_constrained_layout (slot-only updates).""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Type + +from Bridge.UmgLayoutCompiler import UmgLayoutCompiler + +LAYOUT_DSL_VERSION = "2025-06-20-unified-semantics-v1" + + +def is_patch_dsl(dsl_node: Dict[str, Any]) -> bool: + if not isinstance(dsl_node, dict): + return False + if dsl_node.get("patch") is True: + return True + if str(dsl_node.get("mode", "")).lower() == "patch": + return True + if str(dsl_node.get("layout_type", "")).lower() == "patch": + return True + return False + + +async def apply_layout_patch( + dsl_node: Dict[str, Any], + *, + get_tree: Callable[[], Any], + set_widget_properties: Callable[[str, Dict[str, Any]], Any], +) -> Dict[str, Any]: + """Resolve parent containers, compile slot patches, apply via set_widget_properties.""" + tree_resp = await get_tree() + if not tree_resp or tree_resp.get("success") is False: + return { + "status": "error", + "mode": "patch", + "layout_dsl_version": LAYOUT_DSL_VERSION, + "error": tree_resp.get("error") if isinstance(tree_resp, dict) else "get_widget_tree failed.", + } + + parent_map = UmgLayoutCompiler.parse_widget_tree_parents(tree_resp.get("widget_tree", "")) + patches, lint = UmgLayoutCompiler.compile_patch(dsl_node, parent_map) + + if not lint.ok: + return { + "status": "error", + "mode": "patch", + "layout_dsl_version": LAYOUT_DSL_VERSION, + "error": "Layout patch lint failed.", + "lint": lint.to_dict(), + "patches_preview": patches, + } + + if not patches: + return { + "status": "error", + "mode": "patch", + "layout_dsl_version": LAYOUT_DSL_VERSION, + "error": "No valid patch entries to apply.", + "lint": lint.to_dict(), + } + + applied: List[Dict[str, Any]] = [] + errors: List[str] = [] + for patch in patches: + widget_name = patch["widget_name"] + slot_props = patch["slot"] + result = await set_widget_properties(widget_name, {"Slot": slot_props}) + entry = { + "widget_name": widget_name, + "parent_container": patch.get("parent_container"), + "slot": slot_props, + "result": result, + } + applied.append(entry) + if not result or result.get("success") is False: + errors.append(result.get("error") or f"Failed to patch '{widget_name}'.") + + if errors: + return { + "status": "error", + "mode": "patch", + "layout_dsl_version": LAYOUT_DSL_VERSION, + "applied": False, + "error": "; ".join(errors), + "lint": lint.to_dict(), + "patches": applied, + } + + return { + "status": "success", + "mode": "patch", + "layout_dsl_version": LAYOUT_DSL_VERSION, + "applied": True, + "lint": lint.to_dict(), + "patches": applied, + } diff --git a/Resources/Python/Bridge/test_umg_layout_compiler.py b/Resources/Python/Bridge/test_umg_layout_compiler.py new file mode 100644 index 0000000..b7fc0b5 --- /dev/null +++ b/Resources/Python/Bridge/test_umg_layout_compiler.py @@ -0,0 +1,120 @@ +"""Unit tests for UmgLayoutCompiler unified layout semantics (no UE required).""" + +import unittest + +from Bridge.UmgLayoutCompiler import UmgLayoutCompiler, LayoutLint + + +class TestUnifiedLayoutSemantics(unittest.TestCase): + def test_size_fill_compiles_to_box_fill(self): + dsl = { + "layout_type": "flex", + "direction": "column", + "name": "Root", + "children": [ + {"type": "Text", "name": "Title", "size": "auto"}, + {"type": "Border", "name": "Body", "size": "fill", "fill_weight": 2}, + ], + } + compiled, lint = UmgLayoutCompiler.compile(dsl) + self.assertTrue(lint.ok) + body = compiled["children"][1] + self.assertEqual(body["properties"]["Slot"]["Size"]["SizeRule"], "Fill") + self.assertEqual(body["properties"]["Slot"]["Size"]["Value"], 2.0) + title = compiled["children"][0] + self.assertEqual(title["properties"]["Slot"]["Size"]["SizeRule"], "Auto") + + def test_h_align_v_align_on_flex_child(self): + dsl = { + "layout_type": "flex", + "direction": "column", + "name": "Root", + "children": [ + { + "type": "Button", + "name": "Btn", + "size": "fill", + "h_align": "Center", + "v_align": "Center", + }, + ], + } + compiled, _ = UmgLayoutCompiler.compile(dsl) + slot = compiled["children"][0]["properties"]["Slot"] + self.assertEqual(slot["HorizontalAlignment"], "Center") + self.assertEqual(slot["VerticalAlignment"], "Center") + + def test_canvas_size_fill_uses_stretch_anchors(self): + dsl = { + "layout_type": "absolute", + "name": "Canvas", + "children": [ + {"type": "Border", "name": "Overlay", "size": "fill"}, + ], + } + compiled, _ = UmgLayoutCompiler.compile(dsl) + slot = compiled["children"][0]["properties"]["Slot"] + anchors = slot["LayoutData"]["Anchors"] + self.assertEqual(anchors["Minimum"], [0.0, 0.0]) + self.assertEqual(anchors["Maximum"], [1.0, 1.0]) + + def test_canvas_h_align_v_align_to_position(self): + dsl = { + "layout_type": "absolute", + "name": "Canvas", + "children": [ + {"type": "Button", "name": "Ok", "size": "auto", "h_align": "Right", "v_align": "Bottom"}, + ], + } + compiled, _ = UmgLayoutCompiler.compile(dsl) + slot = compiled["children"][0]["properties"]["Slot"] + anchors = slot["LayoutData"]["Anchors"] + self.assertEqual(anchors["Minimum"], [1.0, 1.0]) + self.assertEqual(anchors["Maximum"], [1.0, 1.0]) + + def test_width_100_percent_maps_to_fill(self): + dsl = { + "layout_type": "flex", + "direction": "row", + "name": "Row", + "children": [{"type": "Spacer", "name": "S", "width": "100%"}], + } + compiled, _ = UmgLayoutCompiler.compile(dsl) + slot = compiled["children"][0]["properties"]["Slot"] + self.assertEqual(slot["Size"]["SizeRule"], "Fill") + + def test_parse_widget_tree_parents(self): + tree = """WBP_Test [WidgetBlueprint] + Root [CanvasPanel] + - MainCol [VerticalBox] + - Header [Border] + - Content [Border] +""" + parents = UmgLayoutCompiler.parse_widget_tree_parents(tree) + self.assertEqual(parents["MainCol"], "CanvasPanel") + self.assertEqual(parents["Header"], "VerticalBox") + self.assertEqual(parents["Content"], "VerticalBox") + + def test_patch_misroute_blocked_on_full_compile(self): + dsl = {"patch": True, "updates": [{"name": "A", "size": "fill"}]} + compiled, lint = UmgLayoutCompiler.compile(dsl) + self.assertFalse(lint.ok) + self.assertEqual(compiled, {}) + self.assertTrue(any(i.get("ruleId") == "patch-misrouted" for i in lint.issues)) + dsl = { + "patch": True, + "updates": [ + {"name": "Content", "size": "fill", "h_align": "Center", "v_align": "Fill"}, + ], + } + parent_map = {"Content": "VerticalBox"} + patches, lint = UmgLayoutCompiler.compile_patch(dsl, parent_map) + self.assertTrue(lint.ok) + self.assertEqual(len(patches), 1) + self.assertEqual(patches[0]["widget_name"], "Content") + self.assertEqual(patches[0]["slot"]["Size"]["SizeRule"], "Fill") + self.assertEqual(patches[0]["slot"]["HorizontalAlignment"], "Center") + + +if __name__ == "__main__": + unittest.main() diff --git a/Resources/Python/Catalog/UmgComponentCatalog.py b/Resources/Python/Catalog/UmgComponentCatalog.py new file mode 100644 index 0000000..e474c9b --- /dev/null +++ b/Resources/Python/Catalog/UmgComponentCatalog.py @@ -0,0 +1,17 @@ +# UmgComponentCatalog.py — semantic WBP component catalog client + +from typing import Dict, Any, Optional + + +class UmgComponentCatalog: + def __init__(self, client): + self.client = client + + def catalog_list(self, root: str = "/Game/UI", recursive: bool = True) -> Dict[str, Any]: + return self.client.send_command( + "catalog_list", + {"root": root, "recursive": recursive}, + ) + + def catalog_describe(self, path: str) -> Dict[str, Any]: + return self.client.send_command("catalog_describe", {"path": path}) diff --git a/Resources/Python/Patch/UmgPatchEngine.py b/Resources/Python/Patch/UmgPatchEngine.py new file mode 100644 index 0000000..85003c8 --- /dev/null +++ b/Resources/Python/Patch/UmgPatchEngine.py @@ -0,0 +1,141 @@ +# UmgPatchEngine.py — JSON Patch orchestration (preview in Python, apply via C++ transaction) + +from typing import Dict, Any, List, Optional + + +def parse_patch_path(path: str, op_type: str = "") -> Dict[str, Any]: + """Parse patch paths with the same rules as UUmgPatchSubsystem::ParsePatchPath.""" + normalized = (path or "").strip() + if not normalized.startswith("/widgets/"): + return {"valid": False, "error": f"Invalid patch path (must start with /widgets/): {path}"} + + segments = [segment for segment in normalized[len("/widgets/") :].split("/") if segment] + if not segments: + return {"valid": False, "error": "Missing widget name in patch path."} + + info: Dict[str, Any] = { + "valid": True, + "widget_name": segments[0], + "property_path": "", + "parent_name": segments[0], + "is_child_append": False, + } + + op = (op_type or "").strip().lower() + + if op == "remove": + if len(segments) != 1: + return { + "valid": False, + "error": "remove op path must be exactly /widgets/{WidgetName} (elimination only).", + } + return info + + if len(segments) == 1: + if op == "set": + return {"valid": False, "error": "set op path must include /properties/{PropertyName}."} + if op: + return {"valid": False, "error": f"Unsupported single-segment path for op '{op_type}'."} + return info + + if segments[1] == "properties": + if op and op != "set": + return {"valid": False, "error": f"properties path is only valid for set op, not '{op_type}'."} + if len(segments) < 3: + return {"valid": False, "error": "set op path must include a property name after /properties/."} + info["property_path"] = ".".join(segments[2:]) + return info + + if segments[1] == "children": + if op and op != "add": + return {"valid": False, "error": f"children path is only valid for add op, not '{op_type}'."} + if len(segments) >= 3 and segments[2] == "-": + info["is_child_append"] = True + return info + return {"valid": False, "error": "add op path must be /widgets/{Parent}/children/-."} + + return {"valid": False, "error": f"Unsupported path segments in: {path}"} + + +def describe_op(op: Dict[str, Any]) -> Dict[str, Any]: + op_type = (op.get("op") or "").strip().lower() + path = op.get("path", "") + parsed = parse_patch_path(path, op_type) + summary = { + "op": op_type, + "path": path, + "valid": parsed.get("valid", False), + } + + if not parsed.get("valid"): + summary["error"] = parsed.get("error") + return summary + + if op_type == "set": + summary["action"] = "set_widget_properties" + summary["widget_name"] = parsed["widget_name"] + summary["property"] = parsed.get("property_path") + summary["value"] = op.get("value") + if not summary["property"]: + summary["valid"] = False + summary["error"] = "set op path must include /properties/{PropertyName}." + elif op_type == "add": + summary["action"] = "create_widget" + summary["parent_name"] = parsed["widget_name"] + value = op.get("value") or {} + summary["widget_type"] = value.get("type") or value.get("widget_type") or value.get("widget_class") + summary["new_widget_name"] = value.get("name") or value.get("widget_name") + if not summary["widget_type"] or not summary["new_widget_name"]: + summary["valid"] = False + summary["error"] = "add op value must include type and name." + elif op_type == "remove": + summary["action"] = "eliminate_widget" + summary["widget_name"] = parsed["widget_name"] + else: + summary["valid"] = False + summary["error"] = f"Unsupported op: {op_type or op.get('op', '')}" + + return summary + + +def patch_preview(patch: List[Dict[str, Any]], revision: Optional[int] = None) -> Dict[str, Any]: + planned = [describe_op(op) for op in (patch or [])] + invalid = [item for item in planned if not item.get("valid")] + + return { + "status": "ok" if not invalid else "invalid", + "revision": revision, + "op_count": len(planned), + "planned_ops": planned, + "warnings": [item.get("error") for item in invalid if item.get("error")], + } + + +class UmgPatchEngine: + def __init__(self, client): + self.client = client + + def get_patch_revision(self) -> Dict[str, Any]: + return self.client.send_command("get_patch_revision", {}) + + def patch_preview(self, patch: List[Dict[str, Any]]) -> Dict[str, Any]: + revision_resp = self.get_patch_revision() + revision = None + if isinstance(revision_resp, dict): + revision = revision_resp.get("revision") + if revision is None and isinstance(revision_resp.get("result"), dict): + revision = revision_resp["result"].get("revision") + + preview = patch_preview(patch, revision=revision) + preview["success"] = preview["status"] == "ok" + return preview + + def patch_apply( + self, + patch: List[Dict[str, Any]], + expected_revision: Optional[int] = None, + ) -> Dict[str, Any]: + params: Dict[str, Any] = {"patch": patch} + if expected_revision is not None: + params["expected_revision"] = expected_revision + return self.client.send_command("patch_apply", params) diff --git a/Resources/Python/Theme/UMGTheme.py b/Resources/Python/Theme/UMGTheme.py new file mode 100644 index 0000000..e708a14 --- /dev/null +++ b/Resources/Python/Theme/UMGTheme.py @@ -0,0 +1,68 @@ +# UMGTheme.py — Design token / theme API client + +from typing import Dict, Any, Optional, List, Union + + +def _navigate_theme_path(theme: Dict[str, Any], dot_path: str) -> Any: + current: Any = theme + for segment in dot_path.split("."): + if not segment or not isinstance(current, dict) or segment not in current: + return None + current = current[segment] + return current + + +def resolve_token_in_theme(theme: Dict[str, Any], token_ref: str) -> Any: + ref = (token_ref or "").strip() + if ref.startswith("@"): + ref = ref[1:] + if not ref: + return None + return _navigate_theme_path(theme, ref) + + +def resolve_tokens_deep(theme: Dict[str, Any], value: Any) -> Any: + if isinstance(value, str) and value.startswith("@"): + resolved = resolve_token_in_theme(theme, value) + return value if resolved is None else resolved + if isinstance(value, dict): + return {key: resolve_tokens_deep(theme, item) for key, item in value.items()} + if isinstance(value, list): + return [resolve_tokens_deep(theme, item) for item in value] + return value + + +class UMGTheme: + def __init__(self, client): + self.client = client + + def theme_get(self, theme_path: Optional[str] = None) -> Dict[str, Any]: + params: Dict[str, Any] = {} + if theme_path: + params["theme_path"] = theme_path + return self.client.send_command("theme_get", params) + + def theme_apply(self, patch: Dict[str, Any], theme_path: Optional[str] = None) -> Dict[str, Any]: + params: Dict[str, Any] = {"patch": patch} + if theme_path: + params["theme_path"] = theme_path + return self.client.send_command("theme_apply", params) + + def theme_resolve_token(self, token: str) -> Dict[str, Any]: + return self.client.send_command("theme_resolve_token", {"token": token}) + + @staticmethod + def extract_theme_dict(theme_get_response: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(theme_get_response, dict): + return {} + theme = theme_get_response.get("theme") + if isinstance(theme, dict): + return theme + result = theme_get_response.get("result") + if isinstance(result, dict) and isinstance(result.get("theme"), dict): + return result["theme"] + return {} + + def resolve_tokens_in_layout(self, layout_json: Union[Dict[str, Any], List[Any]], theme: Optional[Dict[str, Any]] = None) -> Union[Dict[str, Any], List[Any]]: + theme_dict = theme or {} + return resolve_tokens_deep(theme_dict, layout_json) diff --git a/Resources/Python/UmgMcpServer.py b/Resources/Python/UmgMcpServer.py index 8763f40..9362ed5 100644 --- a/Resources/Python/UmgMcpServer.py +++ b/Resources/Python/UmgMcpServer.py @@ -35,13 +35,19 @@ from FileManage import UMGAttention from Widget import UMGGet from Widget import UMGSet +from Widget import UMGStorybook +from Theme import UMGTheme +from Catalog import UmgComponentCatalog +from Patch import UmgPatchEngine from FileManage import UMGFileTransformation from Bridge import UMGHTMLParser +from Bridge.UmgLayoutCompiler import UmgLayoutCompiler, LayoutLint +from Bridge.UmgLayoutPatch import is_patch_dsl, apply_layout_patch +from Bridge.UmgAssetLinter import normalize_report, auto_fix_lint_loop, auto_fix_lint_loop, is_auto_applicable_fix from Editor import UMGEditor from Material import UMGMaterial - - +from helpers.mcp_transport import read_until_null_delimiter, summarize_response_for_log # Configure logging logging.basicConfig( level=logging.INFO, @@ -96,33 +102,23 @@ async def send_command(self, command: str, params: Dict[str, Any] = None) -> Opt sys.stderr.write("DEBUG: Async Waiting for response (read 4096 chunks)...\n") sys.stderr.flush() - chunks = [] - while True: - # Read chunk - chunk = await reader.read(4096) - if not chunk: - break # EOF - - if b'\x00' in chunk: - chunks.append(chunk[:chunk.find(b'\x00')]) - break - chunks.append(chunk) - - response_data = b"".join(chunks) + response_data = await read_until_null_delimiter(reader) sys.stderr.write(f"DEBUG: Async Received {len(response_data)} bytes.\n") sys.stderr.flush() response_str = response_data.decode('utf-8') - logger.info(f"[UMGMCP-Message] Received: {response_str}") + logger.info( + "[UMGMCP-Message] Received (%d bytes): %s", + len(response_data), + summarize_response_for_log(response_str), + ) if not response_str: raise ConnectionError("Empty response from Unreal") response = json.loads(response_str) - - # Log complete response for debugging - logger.info(f"Complete response from Unreal: {response}") + logger.info("Complete response from Unreal: %s", summarize_response_for_log(response)) # Check for error formats if response.get("status") == "error": @@ -499,14 +495,213 @@ async def get_layout_data(resolution_width: int = 1920, resolution_height: int = umg_get_client = UMGGet.UMGGet(conn) return await umg_get_client.get_layout_data(resolution_width, resolution_height) +@register_tool("lint_umg_asset", "Runs ESLint-style lint checks on the Active Target UMG asset.") +async def lint_umg_asset( + rules: Optional[List[str]] = None, + viewport_w: int = 1920, + viewport_h: int = 1080, + depth_threshold: Optional[int] = None, +) -> Dict[str, Any]: + """ + (Description loaded from prompts.json) + """ + conn = get_unreal_connection() + umg_get_client = UMGGet.UMGGet(conn) + result = await umg_get_client.lint_umg_asset( + rules=rules, + viewport_w=viewport_w, + viewport_h=viewport_h, + depth_threshold=depth_threshold, + ) + return normalize_report(result) + +@register_tool("auto_fix_lint", "Runs bounded lint/auto-fix iterations for auto-applicable overlap fixes.") +async def auto_fix_lint( + rules: Optional[List[str]] = None, + viewport_w: int = 1920, + viewport_h: int = 1080, + max_iterations: int = 3, +) -> Dict[str, Any]: + """ + Lint loop with stall detection. Only auto-applies fixSuggestion marked meta.autoApplicable. + """ + conn = get_unreal_connection() + umg_get_client = UMGGet.UMGGet(conn) + umg_set_client = UMGSet.UMGSet(conn) + + async def lint_fn(): + return await umg_get_client.lint_umg_asset( + rules=rules, + viewport_w=viewport_w, + viewport_h=viewport_h, + ) + + async def apply_fix_fn(params: Dict[str, Any]): + widget_name = params.get("widget_name", "") + properties = params.get("properties", {}) + if not widget_name or not properties: + return {"success": False, "error": "fixSuggestion params missing widget_name/properties."} + return await umg_set_client.set_widget_properties(widget_name, properties) + + result = await auto_fix_lint_loop( + lint_fn, + apply_fix_fn, + max_iterations=max(1, min(max_iterations, 5)), + ) + if "report" in result: + result["report"] = normalize_report(result["report"]) + return result + @register_tool("check_widget_overlap", "Checks for widget overlap.") async def check_widget_overlap(widget_names: Optional[List[str]] = None) -> Dict[str, Any]: """ (Description loaded from prompts.json) + Legacy wrapper around lint_umg_asset(layout-overlap only). """ conn = get_unreal_connection() umg_get_client = UMGGet.UMGGet(conn) - return await umg_get_client.check_widget_overlap(widget_names) + result = await umg_get_client.lint_umg_asset(rules=["layout-overlap"]) + normalized = normalize_report(result) + has_overlap = any( + issue.get("ruleId") == "layout-overlap" + for issue in normalized.get("issues", []) + ) + normalized["has_overlap"] = has_overlap or result.get("has_overlap", False) + return normalized + +# ============================================================================= +# Category: Storybook / Visual Preview +# ============================================================================= + +@register_tool("render_widget_preview", "Renders an isolated widget preview screenshot.") +async def render_widget_preview( + asset_path: Optional[str] = None, + widget_name: Optional[str] = None, + viewport_w: int = 400, + viewport_h: int = 300, + theme: Optional[str] = None, +) -> Dict[str, Any]: + """ + Renders via C++ TakeWidget + SVirtualWindow off-screen path (no Designer required). + Omits widget_name unless explicitly set so the full UserWidget is captured for AI review. + """ + conn = get_unreal_connection() + storybook_client = UMGStorybook.UMGStorybook(conn) + + resolved_path = asset_path or context_manager.get_target() + # Only pass widget_name when the caller explicitly requests a subtree preview. + explicit_widget_name = widget_name if widget_name is not None else None + + params_path = normalize_project_path(resolved_path) if resolved_path else None + result = await storybook_client.render_widget_preview( + asset_path=params_path, + widget_name=explicit_widget_name, + viewport_w=viewport_w, + viewport_h=viewport_h, + theme=theme, + ) + + if isinstance(result, dict): + err = result.get("error") or "" + if "no root widget" in str(err).lower(): + result["hint"] = ( + "Create a panel root first, e.g. create_widget(widget_type='VerticalBox', new_widget_name='Root')." + ) + + return result + +@register_tool("storybook_list_variants", "Lists storybook widget variants.") +async def storybook_list_variants( + asset_path: Optional[str] = None, + parent_widget: Optional[str] = None, + catalog_component_id: Optional[str] = None, +) -> Dict[str, Any]: + """ + (Description loaded from prompts.json) + """ + conn = get_unreal_connection() + storybook_client = UMGStorybook.UMGStorybook(conn) + + resolved_path = asset_path or context_manager.get_target() + params_path = normalize_project_path(resolved_path) if resolved_path else None + return await storybook_client.storybook_list_variants( + asset_path=params_path, + parent_widget=parent_widget, + catalog_component_id=catalog_component_id, + ) + +@register_tool("storybook_render", "Batch-renders storybook widget variant screenshots.") +async def storybook_render( + asset_path: Optional[str] = None, + widget_names: Optional[List[str]] = None, + viewport_w: int = 400, + viewport_h: int = 300, + theme: Optional[str] = None, +) -> Dict[str, Any]: + """ + (Description loaded from prompts.json) + """ + conn = get_unreal_connection() + storybook_client = UMGStorybook.UMGStorybook(conn) + + resolved_path = asset_path or context_manager.get_target() + params_path = normalize_project_path(resolved_path) if resolved_path else None + return await storybook_client.storybook_render( + asset_path=params_path, + widget_names=widget_names, + viewport_w=viewport_w, + viewport_h=viewport_h, + theme=theme, + ) + +# ============================================================================= +# Category: Theme / Catalog / Patch Orchestration +# ============================================================================= + +@register_tool("theme_get", "Read design tokens from theme.json.") +async def theme_get(theme_path: Optional[str] = None) -> Dict[str, Any]: + conn = get_unreal_connection() + client = UMGTheme.UMGTheme(conn) + return await client.theme_get(theme_path) + +@register_tool("theme_apply", "Merge a patch into theme.json and reload token cache.") +async def theme_apply(patch: Dict[str, Any], theme_path: Optional[str] = None) -> Dict[str, Any]: + conn = get_unreal_connection() + client = UMGTheme.UMGTheme(conn) + return await client.theme_apply(patch, theme_path) + +@register_tool("theme_resolve_token", "Resolve a @token reference against the active theme.") +async def theme_resolve_token(token: str) -> Dict[str, Any]: + conn = get_unreal_connection() + client = UMGTheme.UMGTheme(conn) + return await client.theme_resolve_token(token) + +@register_tool("catalog_list", "List reusable WBP components under a content root.") +async def catalog_list(root: str = "/Game/UI", recursive: bool = True) -> Dict[str, Any]: + conn = get_unreal_connection() + client = UmgComponentCatalog.UmgComponentCatalog(conn) + return await client.catalog_list(root=root, recursive=recursive) + +@register_tool("catalog_describe", "Describe NamedSlots and Expose-on-Spawn params for a WBP.") +async def catalog_describe(path: str) -> Dict[str, Any]: + conn = get_unreal_connection() + client = UmgComponentCatalog.UmgComponentCatalog(conn) + return await client.catalog_describe(path) + +@register_tool("patch_preview", "Preview planned patch ops without applying.") +async def patch_preview_tool(patch: List[Dict[str, Any]]) -> Dict[str, Any]: + conn = get_unreal_connection() + engine = UmgPatchEngine.UmgPatchEngine(conn) + return await engine.patch_preview(patch) + +@register_tool("patch_apply", "Apply patch ops atomically inside one undo transaction.") +async def patch_apply_tool( + patch: List[Dict[str, Any]], + expected_revision: Optional[int] = None, +) -> Dict[str, Any]: + conn = get_unreal_connection() + engine = UmgPatchEngine.UmgPatchEngine(conn) + return await engine.patch_apply(patch, expected_revision=expected_revision) # ============================================================================= # Category: Action @@ -547,13 +742,21 @@ async def delete_widget(widget_name: str) -> Dict[str, Any]: return await umg_set_client.delete_widget(widget_name) @register_tool("reparent_widget", "Moves a widget to a new parent.") -async def reparent_widget(widget_name: str, new_parent_name: str) -> Dict[str, Any]: +async def reparent_widget( + widget_name: str, + new_parent_name: Optional[str] = None, + new_parent_widget: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: """ - (Description loaded from prompts.json) + Simple parent move via new_parent_name, or widget conversion via new_parent_widget JSON. """ conn = get_unreal_connection() umg_set_client = UMGSet.UMGSet(conn) - return await umg_set_client.reparent_widget(widget_name, new_parent_name) + return await umg_set_client.reparent_widget( + widget_name, + new_parent_widget=new_parent_widget, + new_parent_name=new_parent_name, + ) @register_tool("save_asset", "Saves the UMG asset.") async def save_asset() -> Dict[str, Any]: @@ -626,6 +829,79 @@ async def apply_layout(layout_content: str, widget_name: Optional[str] = None) - umg_trans_client = UMGFileTransformation.UMGFileTransformation(conn) return await umg_trans_client.apply_json_to_umg(final_path, json_data, target_widget) +@register_tool( + "apply_constrained_layout", + "Applies unified layout DSL (flex/grid/absolute full tree, or patch:true slot updates). Preferred over raw apply_layout.", +) +async def apply_constrained_layout( + dsl_json: str, + widget_name: Optional[str] = None, +) -> Dict[str, Any]: + """ + Compiles unified layout DSL to UMG JSON, runs lint, then applies. + Patch mode: {\"patch\": true, \"updates\": [{\"name\": \"Widget\", \"size\": \"fill\", ...}]} + """ + try: + dsl_node = json.loads(dsl_json.strip()) + except json.JSONDecodeError as e: + return {"status": "error", "error": f"DSL JSON Parsing Failed: {str(e)}"} + + if not isinstance(dsl_node, dict): + return {"status": "error", "error": "dsl_json must be a JSON object (root layout node)."} + + if is_patch_dsl(dsl_node): + conn = get_unreal_connection() + umg_get_client = UMGGet.UMGGet(conn) + umg_set_client = UMGSet.UMGSet(conn) + return await apply_layout_patch( + dsl_node, + get_tree=umg_get_client.get_widget_tree, + set_widget_properties=umg_set_client.set_widget_properties, + ) + + compiled, lint = UmgLayoutCompiler.compile(dsl_node) + if not lint.ok: + return { + "status": "error", + "error": "Layout lint failed. Fix errors before applying.", + "lint": lint.to_dict(), + "compiled_preview": compiled, + } + + conn = get_unreal_connection() + theme_client = UMGTheme.UMGTheme(conn) + theme_resp = await theme_client.theme_get() + theme_dict = UMGTheme.UMGTheme.extract_theme_dict(theme_resp) + compiled = theme_client.resolve_tokens_in_layout(compiled, theme_dict) + + layout_content = json.dumps(compiled, ensure_ascii=False) + result = await apply_layout(layout_content, widget_name) + + applied = result.get("applied") is True or ( + result.get("success") is True and result.get("applied") is not False + ) + if result.get("status") == "error" or result.get("success") is False or not applied: + return { + "status": "error", + "error": result.get("error") or "Layout apply was not confirmed by editor.", + "applied": False, + "lint": lint.to_dict(), + "compiled_layout": compiled, + "apply_result": result, + } + + return { + "status": "success", + "mode": "full", + "applied": True, + "confirmed_applied": True, + "lint": lint.to_dict(), + "compiled_layout": compiled, + "reparented_widgets": result.get("reparented_widgets", []), + "apply_result": result, + } + + @mcp.tool() async def apply_json_to_umg(asset_path: str, json_data: dict, widget_name: Optional[str] = None) -> Dict[str, Any]: """Applies a JSON definition to a UMG asset. (Maintained for backward compatibility and specialized agent workflows)""" diff --git a/Resources/Python/UmgMcpSkills.py b/Resources/Python/UmgMcpSkills.py index fae76b5..210946e 100644 --- a/Resources/Python/UmgMcpSkills.py +++ b/Resources/Python/UmgMcpSkills.py @@ -18,6 +18,8 @@ UNREAL_HOST = "127.0.0.1" UNREAL_PORT = 7999 +from helpers.mcp_transport import read_until_null_delimiter, summarize_response_for_log + class UnrealConnection: """Connection helper for Skill mode.""" async def send_command(self, command: str, params: Dict[str, Any] = None) -> Dict[str, Any]: @@ -29,9 +31,14 @@ async def send_command(self, command: str, params: Dict[str, Any] = None) -> Dic if writer.can_write_eof(): writer.write_eof() - - data = await reader.read(1024 * 1024) # 1MB buffer - response_str = data.decode('utf-8').split('\0')[0] + + response_data = await read_until_null_delimiter(reader) + response_str = response_data.decode('utf-8') + logger.info( + "[UMGMCP-Message] Received (%d bytes): %s", + len(response_data), + summarize_response_for_log(response_str), + ) writer.close() await writer.wait_closed() return json.loads(response_str) diff --git a/Resources/Python/Widget/UMGGet.py b/Resources/Python/Widget/UMGGet.py index de5ea0d..0fad170 100644 --- a/Resources/Python/Widget/UMGGet.py +++ b/Resources/Python/Widget/UMGGet.py @@ -36,4 +36,22 @@ def check_widget_overlap(self, widget_names: Optional[List[str]] = None) -> Dict params = {} if widget_names: params["widget_names"] = widget_names - return self.client.send_command("check_widget_overlap", params) \ No newline at end of file + return self.client.send_command("check_widget_overlap", params) + + def lint_umg_asset( + self, + rules: Optional[List[str]] = None, + viewport_w: int = 1920, + viewport_h: int = 1080, + depth_threshold: Optional[int] = None, + ) -> Dict[str, Any]: + """Runs ESLint-style lint checks on the active UMG asset.""" + params: Dict[str, Any] = { + "viewport_w": viewport_w, + "viewport_h": viewport_h, + } + if rules: + params["rules"] = rules + if depth_threshold is not None: + params["depth_threshold"] = depth_threshold + return self.client.send_command("lint_umg_asset", params) \ No newline at end of file diff --git a/Resources/Python/Widget/UMGSet.py b/Resources/Python/Widget/UMGSet.py index 3c2ad72..20e58a1 100644 --- a/Resources/Python/Widget/UMGSet.py +++ b/Resources/Python/Widget/UMGSet.py @@ -1,7 +1,7 @@ # UMGSet.py import json -from typing import Dict, Any, List, Optional +from typing import Dict, Any, List, Optional, Union class UMGSet: def __init__(self, client): @@ -26,11 +26,33 @@ def delete_widget(self, widget_name: str) -> Dict[str, Any]: params = {"widget_name": widget_name} return self.client.send_command("delete_widget", params) - def reparent_widget(self, widget_name: str, new_parent_name: str) -> Dict[str, Any]: - """Moves a widget to be a child of a different parent.""" - params = {"widget_name": widget_name, "new_parent_name": new_parent_name} + def move_widget(self, widget_name: str, target: str) -> Dict[str, Any]: + """Moves a widget under a different parent panel.""" + params = {"widget_name": widget_name, "target": target} + return self.client.send_command("move_widget", params) + + def reparent_widget( + self, + widget_name: str, + new_parent_widget: Optional[Dict[str, Any]] = None, + new_parent_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Converts/reparents a widget using the C++ new_parent_widget JSON spec. + For simple parent moves, pass new_parent_name and it will call move_widget. + """ + if new_parent_name and not new_parent_widget: + return self.move_widget(widget_name, new_parent_name) + + if not new_parent_widget: + return { + "success": False, + "error": "reparent_widget requires new_parent_widget JSON or new_parent_name for move_widget.", + } + + params = {"widget_name": widget_name, "new_parent_widget": new_parent_widget} return self.client.send_command("reparent_widget", params) def save_asset(self) -> Dict[str, Any]: """Saves the UMG asset.""" - return self.client.send_command("save_asset", {}) \ No newline at end of file + return self.client.send_command("save_asset", {}) diff --git a/Resources/Python/Widget/UMGStorybook.py b/Resources/Python/Widget/UMGStorybook.py new file mode 100644 index 0000000..803a88d --- /dev/null +++ b/Resources/Python/Widget/UMGStorybook.py @@ -0,0 +1,63 @@ +# UMGStorybook.py - Isolated widget preview / screenshot tools + +from typing import Dict, Any, List, Optional + + +class UMGStorybook: + def __init__(self, client): + self.client = client + + def render_widget_preview( + self, + asset_path: Optional[str] = None, + widget_name: Optional[str] = None, + viewport_w: int = 400, + viewport_h: int = 300, + theme: Optional[str] = None, + ) -> Dict[str, Any]: + params: Dict[str, Any] = { + "viewport_w": viewport_w, + "viewport_h": viewport_h, + } + if asset_path: + params["asset_path"] = asset_path + if widget_name: + params["widget_name"] = widget_name + if theme: + params["theme"] = theme + return self.client.send_command("render_widget_preview", params) + + def storybook_list_variants( + self, + asset_path: Optional[str] = None, + parent_widget: Optional[str] = None, + catalog_component_id: Optional[str] = None, + ) -> Dict[str, Any]: + params: Dict[str, Any] = {} + if asset_path: + params["asset_path"] = asset_path + if parent_widget: + params["parent_widget"] = parent_widget + if catalog_component_id: + params["catalog_component_id"] = catalog_component_id + return self.client.send_command("storybook_list_variants", params) + + def storybook_render( + self, + asset_path: Optional[str] = None, + widget_names: Optional[List[str]] = None, + viewport_w: int = 400, + viewport_h: int = 300, + theme: Optional[str] = None, + ) -> Dict[str, Any]: + params: Dict[str, Any] = { + "viewport_w": viewport_w, + "viewport_h": viewport_h, + } + if asset_path: + params["asset_path"] = asset_path + if widget_names: + params["widget_names"] = widget_names + if theme: + params["theme"] = theme + return self.client.send_command("storybook_render", params) diff --git a/Resources/Python/helpers/mcp_transport.py b/Resources/Python/helpers/mcp_transport.py new file mode 100644 index 0000000..359ef6e --- /dev/null +++ b/Resources/Python/helpers/mcp_transport.py @@ -0,0 +1,59 @@ +import hashlib +import json +from typing import Any, Dict, Union + + +async def read_until_null_delimiter(reader, chunk_size: int = 4096) -> bytes: + """Read from an async stream until the first null delimiter or EOF.""" + chunks = [] + while True: + chunk = await reader.read(chunk_size) + if not chunk: + break + if b"\x00" in chunk: + chunks.append(chunk[: chunk.find(b"\x00")]) + break + chunks.append(chunk) + return b"".join(chunks) + + +def _redact_image_field(container: Dict[str, Any], field_name: str = "image_base64") -> None: + value = container.get(field_name) + if not isinstance(value, str) or not value: + return + + encoded = value.encode("utf-8") + container.pop(field_name, None) + container["image_base64_bytes"] = len(encoded) + container["image_base64_sha256"] = hashlib.sha256(encoded).hexdigest()[:16] + + +def summarize_response_for_log(payload: Union[str, Dict[str, Any]]) -> str: + """Return a compact log-safe summary that never includes raw base64 image data.""" + if isinstance(payload, str): + try: + data = json.loads(payload) + except json.JSONDecodeError: + return f"" + else: + data = payload + + if not isinstance(data, dict): + return str(data) + + summary: Dict[str, Any] = dict(data) + _redact_image_field(summary) + + renders = summary.get("renders") + if isinstance(renders, list): + redacted_renders = [] + for item in renders: + if isinstance(item, dict): + item_copy = dict(item) + _redact_image_field(item_copy) + redacted_renders.append(item_copy) + else: + redacted_renders.append(item) + summary["renders"] = redacted_renders + + return json.dumps(summary, ensure_ascii=False) diff --git a/Resources/prompts.json b/Resources/prompts.json index b91bd4c..97cedc9 100644 --- a/Resources/prompts.json +++ b/Resources/prompts.json @@ -1,5 +1,5 @@ { - "system_instruction": "You are an expert Unreal Engine UMG assistant. \n\n### CORE CONCEPT: THE 'ACTIVE TARGET'\nYour work revolves around an **'Active Target'**\u2014the specific UMG Widget Blueprint you are currently editing. \n\n### GLOBAL TOOL BEHAVIOR\n**Unless explicitly stated otherwise**, all tools in the 'UMG' and 'Animation' categories implicitly operate on this **Active Target**. You DO NOT need to provide the 'asset_path' argument if the target is already set.\n\n### DEFAULT OPERATION MECHANISM\nThe system determines the **Active Target** using a 4-level Fallback Priority:\n1. **Explicit Parameter**: 'asset_path' provided in the tool call.\n2. **Cached Target**: Set via 'set_target_umg_asset'.\n3. **Editor Focus**: The currently focused asset in the UE Editor.\n4. **History**: The last edited asset.\n\n### WORKFLOW PROTOCOL\n1. **Establish Context**: Start by setting the **Active Target** (`set_target_umg_asset`) and optionally the **Active Widget** (`set_active_widget`).\n2. **Discover Assets**: When setting properties that require an asset (Texture, Material, etc.), you MUST use `list_assets` to valid paths. Do not guess paths.\n3. **Implicit Parenting**: `create_widget` automatically attaches to the **Active Widget** if no parent is specified. This enables fluent, breadth-first or depth-first tree construction.\n4. **Modify**: Issue commands like `create_widget` or `set_widget_properties` without repeating paths or parents if context is set.\n5. **Animation**: Use `set_animation_data` for complex, multi-track animations. Supported property types include Float (e.g., `RenderOpacity`), Vector2D (e.g., `RenderTransform.Translation`, `RenderTransform.Scale`), and LinearColor (e.g., `ColorAndOpacity`).\n\n### IMPORTANT: CREATING WIDGETS\n- **Implicit Parent**: If you omit `parent_name`, the system uses the **Active Widget** (set via `set_active_widget`).\n- **Root Fallback**: If no Active Widget is set, it defaults to the **Root Widget**.\n- **Empty Tree**: If the tree is empty, the first widget MUST be a Panel (e.g. CanvasPanel) and becomes Root.\n- **Widget Prefer**: Autosize Widget is good, like Vertical Box. Don't use CanvasPanel since it isn't autosize\n\n### BLUEPRINT EDITING WORKFLOW\n1. **Set Target**: Use `set_edit_function` to focus on a specific Function or Graph (e.g., 'EventGraph', 'MyFunction').\n2. **Add Logic**: Use `add_function_step` to append logic nodes to the **Active function**. The system automatically handles wiring to the 'Cursor Node'.\n3. **prepare date**: for those couldn't run node,Use `prepare_data` to place it and use `connect` to set.\n4. **Compile**: Use `compile_blueprint` (no args) to apply changes.\n\n### MATERIAL EDITING WORKFLOW (The 5 Pillars)\n1. **Set Target**: Use `material_set_target` to specify the asset.\n2. **Define Interface**: Use `material_define_variable` for Scalar/Vector/Texture parameters.\n3. **Add Nodes**: Use `material_add_node` to place Expressions (e.g. 'Add', 'Multiply', 'TextureSample').\n4. **Connect Logic**: \n - **Natural**: `material_connect_nodes(A, B)` for simple flow.\n - **Surgical**: `material_connect_pins(A, Pin, B, Pin)` for specific wiring.\n - **Output**: Connect the final node to 'Master' or 'Output' to see results.\n5. **Tactical Code**: Use `material_set_hlsl_node_io` to inject HLSL into Custom nodes.\n6. **Apply**: Use `material_compile_asset` to save and update the material shader.\n\n### HLSL TEXT-EDIT WORKFLOW (Recommended for UMG)\n1. **Set Target**: `hlsl_set_target(path)` to lock/create a UI material with single Custom HLSL topology.\n2. **Read**: `hlsl_get()` to retrieve current HLSL code + structured parameters.\n3. **Write Incrementally**: `hlsl_set(hlsl=?, parameters=?)`.\n - Sending only `hlsl` updates code without changing parameters.\n - Sending only `parameters` updates parameter wiring without changing code.\n - Deletion is explicit: include `{ \"name\": \"Param\", \"delete\": true }`.\n4. **Compile**: `hlsl_compile()` to get concise compile diagnostics.\n5. **Output Contract**: Return value is treated as `float4`; backend auto-wires `.rgb -> FinalColor`, `.a -> Opacity`.\n\n### HANDLING EVENTS (INTERACTION)\nTo make UI interactive (e.g., Button Clicks):\n1. **Bind Event**: Use `set_edit_function` with 'WidgetName.EventName' syntax.\n - Example: `set_edit_function('MyButton.OnClicked')`.\n - This automatically creates the event node in the EventGraph and focuses it.\n2. **Add Logic**: Simply add steps. The cursor is already set to the event.\n - Example: `add_function_step('PrintString', args=['Clicked!'])`.", + "system_instruction": "You are an expert Unreal Engine UMG assistant. \n\n### CORE CONCEPT: THE 'ACTIVE TARGET'\nYour work revolves around an **'Active Target'**\u2014the specific UMG Widget Blueprint you are currently editing. \n\n### GLOBAL TOOL BEHAVIOR\n**Unless explicitly stated otherwise**, all tools in the 'UMG' and 'Animation' categories implicitly operate on this **Active Target**. You DO NOT need to provide the 'asset_path' argument if the target is already set.\n\n### DEFAULT OPERATION MECHANISM\nThe system determines the **Active Target** using a 4-level Fallback Priority:\n1. **Explicit Parameter**: 'asset_path' provided in the tool call.\n2. **Cached Target**: Set via 'set_target_umg_asset'.\n3. **Editor Focus**: The currently focused asset in the UE Editor.\n4. **History**: The last edited asset.\n\n### WORKFLOW PROTOCOL\n1. **Establish Context**: Start by setting the **Active Target** (`set_target_umg_asset`) and optionally the **Active Widget** (`set_active_widget`).\n2. **Discover Assets**: When setting properties that require an asset (Texture, Material, etc.), you MUST use `list_assets` to valid paths. Do not guess paths.\n3. **Implicit Parenting**: `create_widget` automatically attaches to the **Active Widget** if no parent is specified. This enables fluent, breadth-first or depth-first tree construction.\n4. **Modify**: Issue commands like `create_widget` or `set_widget_properties` without repeating paths or parents if context is set.\n5. **Animation**: Use `set_animation_data` for complex, multi-track animations. Supported property types include Float (e.g., `RenderOpacity`), Vector2D (e.g., `RenderTransform.Translation`, `RenderTransform.Scale`), and LinearColor (e.g., `ColorAndOpacity`).\n\n### IMPORTANT: CREATING WIDGETS\n- **Implicit Parent**: If you omit `parent_name`, the system uses the **Active Widget** (set via `set_active_widget`).\n- **Root Fallback**: If no Active Widget is set, it defaults to the **Root Widget**.\n- **Empty Tree**: If the tree is empty, the first widget MUST be a Panel (e.g. CanvasPanel) and becomes Root.\n- **Widget Prefer**: Autosize Widget is good, like Vertical Box. Don't use CanvasPanel since it isn't autosize\n\n### CONSTRAINED LAYOUT DSL (PREFERRED)\nUse `apply_constrained_layout` instead of raw `apply_layout` for building UI structure.\n- **flex** (default): `layout_type: flex`, `direction: row|column`, `gap`, `align`, `justify`, children with `flex_grow`.\n- **grid**: `layout_type: grid`, children with `row`, `col`, `row_span`, `col_span`.\n- **absolute**: `layout_type: absolute` ONLY when pixel/anchor positioning is required. Children use `position` presets (`center`, `top-left`, `fill`, etc.) or explicit `anchors`/`offsets`.\n- **Slot ownership**: Layout props (Size, Padding, Alignment) belong to the **parent Slot**, not the widget. The compiler maps them automatically.\n- **Do NOT** use CanvasPanel/anchors under VerticalBox or HorizontalBox — they will be discarded by lint.\n- **Single-child panels** (Button, Border, SizeBox): only one child; compiler auto-wraps multiple children in a VerticalBox.\n\n**Example DSL:**\n```json\n{\"layout_type\":\"flex\",\"direction\":\"column\",\"gap\":10,\"align\":\"center\",\"name\":\"Root\",\"children\":[{\"type\":\"Text\",\"text\":\"Header\",\"flex_grow\":0},{\"type\":\"Image\",\"name\":\"Icon\",\"flex_grow\":1}]}\n```\n### BLUEPRINT EDITING WORKFLOW\n1. **Set Target**: Use `set_edit_function` to focus on a specific Function or Graph (e.g., 'EventGraph', 'MyFunction').\n2. **Add Logic**: Use `add_function_step` to append logic nodes to the **Active function**. The system automatically handles wiring to the 'Cursor Node'.\n3. **prepare date**: for those couldn't run node,Use `prepare_data` to place it and use `connect` to set.\n4. **Compile**: Use `compile_blueprint` (no args) to apply changes.\n\n### MATERIAL EDITING WORKFLOW (The 5 Pillars)\n1. **Set Target**: Use `material_set_target` to specify the asset.\n2. **Define Interface**: Use `material_define_variable` for Scalar/Vector/Texture parameters.\n3. **Add Nodes**: Use `material_add_node` to place Expressions (e.g. 'Add', 'Multiply', 'TextureSample').\n4. **Connect Logic**: \n - **Natural**: `material_connect_nodes(A, B)` for simple flow.\n - **Surgical**: `material_connect_pins(A, Pin, B, Pin)` for specific wiring.\n - **Output**: Connect the final node to 'Master' or 'Output' to see results.\n5. **Tactical Code**: Use `material_set_hlsl_node_io` to inject HLSL into Custom nodes.\n6. **Apply**: Use `material_compile_asset` to save and update the material shader.\n\n### HLSL TEXT-EDIT WORKFLOW (Recommended for UMG)\n1. **Set Target**: `hlsl_set_target(path)` to lock/create a UI material with single Custom HLSL topology.\n2. **Read**: `hlsl_get()` to retrieve current HLSL code + structured parameters.\n3. **Write Incrementally**: `hlsl_set(hlsl=?, parameters=?)`.\n - Sending only `hlsl` updates code without changing parameters.\n - Sending only `parameters` updates parameter wiring without changing code.\n - Deletion is explicit: include `{ \"name\": \"Param\", \"delete\": true }`.\n4. **Compile**: `hlsl_compile()` to get concise compile diagnostics.\n5. **Output Contract**: Return value is treated as `float4`; backend auto-wires `.rgb -> FinalColor`, `.a -> Opacity`.\n\n### HANDLING EVENTS (INTERACTION)\nTo make UI interactive (e.g., Button Clicks):\n1. **Bind Event**: Use `set_edit_function` with 'WidgetName.EventName' syntax.\n - Example: `set_edit_function('MyButton.OnClicked')`.\n - This automatically creates the event node in the EventGraph and focuses it.\n2. **Add Logic**: Simply add steps. The cursor is already set to the event.\n - Example: `add_function_step('PrintString', args=['Clicked!'])`.\n\n### DESIGN TOKENS (Theme First)\nBefore setting colors, spacing, or font sizes, call `theme_get`. Prefer `@token` syntax (e.g. `@color.primary`, `@spacing.md`) in `set_widget_properties` and layout JSON. Do NOT use raw hex or magic numbers unless the token is missing — then extend via `theme_apply`.\n\n### COMPONENT CATALOG (Catalog First)\nBefore creating UI, call `catalog_list`. Reuse existing WBP components (e.g. `WBP_CommonButton`, `WBP_BuildPhase*`) when semantics match; use `catalog_describe` for slots and Expose-on-Spawn params.\n\n### ATOMIC PATCHING\nFor multi-step edits, call `patch_preview` first, then `patch_apply` to commit in one undo transaction. Pass `expected_revision` from preview when available. Paths: `/widgets/{Name}/properties/{Prop}`, `/widgets/{Parent}/children/-`, `/widgets/{Name}` for remove (elimination only).\n\n### LINT GATE (UMG Quality Check)\nAfter structural UI changes (`apply_constrained_layout`, `apply_layout`, `create_widget`, `set_widget_properties`, `reparent_widget`):\n1. **Run** `lint_umg_asset` on the Active Target.\n2. **Gate preview on trust**: Proceed to `render_widget_preview` only when `ok=true` AND `geometryTrusted=true`. If `geometryTrusted=false`, fix preview-not-ready first.\n3. **Fix errors first**: Resolve all `severity=error` issues before preview.\n4. **Use fixSuggestion carefully**: Only auto-apply suggestions with `fixSuggestion.meta.autoApplicable=true`. Others require manual `query_widget_properties` + compatible Slot edits.\n5. **Bounded retries**: Prefer `auto_fix_lint(max_iterations=3)` for overlap fixes; stop when status is `stalled`, `manual_required`, or `geometry_not_trusted`.\n6. **Re-lint** after manual fixes until `ok=true`.\n\n**Lint report schema**: `{ ok, geometryTrusted, summary: {error, warning, info}, issues: [{ ruleId, severity, message, widgetPath, fixSuggestion?, fixSuggestion.meta? }] }`.\n**Rules**: `layout-overlap`, `nesting-depth-limit`, `naming-convention`. Filter via optional `rules` param.\n**Note**: `reparent_widget(new_parent_name=...)` performs a simple parent move; widget class conversion requires `new_parent_widget` JSON. `nesting-depth-limit` has no auto fix — split into a nested UserWidget manually.", "tools": [ { "name": "get_widget_schema", @@ -61,12 +61,42 @@ "enabled": true, "category": "UMG" }, + { + "name": "lint_umg_asset", + "description": "Runs ESLint-style lint on the Active Target UMG asset. Returns `{ ok, geometryTrusted, summary, issues[] }`. Overlap geometry uses off-screen FWidgetRenderer arrange (same path as preview). fixSuggestion is slot-aware and only auto-applicable when `fixSuggestion.meta.autoApplicable=true`. Optional: `rules`, `viewport_w`, `viewport_h`, `depth_threshold`.", + "enabled": true, + "category": "UMG" + }, + { + "name": "auto_fix_lint", + "description": "Bounded lint/fix loop (default max 3). Auto-applies only overlap fixes marked `meta.autoApplicable`. Stops on unchanged issues, apply failure, geometryTrusted=false, or no auto-fixable errors. Returns `{ status, report, history }`.", + "enabled": true, + "category": "UMG" + }, { "name": "check_widget_overlap", - "description": "Checks for visual overlaps between widgets.", + "description": "Deprecated — use `lint_umg_asset` with rules=[\"layout-overlap\"]. Legacy overlap-only check.", "enabled": false, "category": "UMG" }, + { + "name": "render_widget_preview", + "description": "Renders an isolated off-screen preview (TakeWidget + SVirtualWindow + SlatePrepass). Does NOT require UMG Designer to be open. \n**Scope**: Active Target (or explicit `asset_path`). \n**Optional**: `widget_name` (defaults to Active Widget or root). Requires a root panel in the widget tree. \n**Returns**: `{status, image_base64, width, height, widget_path}`. \n**Note**: BindWidget/ViewModel/PIE-only logic may still render empty.", + "enabled": true, + "category": "UMG" + }, + { + "name": "storybook_list_variants", + "description": "Lists storybook variants for the Active Target. Uses `storybook_catalog.json` when matched, otherwise returns direct children of root (or `parent_widget`).", + "enabled": true, + "category": "UMG" + }, + { + "name": "storybook_render", + "description": "Batch-renders storybook variant screenshots. Provide `widget_names` or auto-discover via `storybook_list_variants`. Returns an array of preview results with base64 images.", + "enabled": true, + "category": "UMG" + }, { "name": "create_widget", "description": "Adds a new widget. \n**Implicit Parent**: If `parent_name` is omitted, uses implicit parent (Active Widget -> Root).", @@ -87,7 +117,7 @@ }, { "name": "reparent_widget", - "description": "Moves a widget to a new parent.", + "description": "Moves a widget to a new parent. For simple parent moves use `new_parent_name` (calls move_widget). For widget class conversion pass `new_parent_widget` JSON with widget_class/widget_type.", "enabled": true, "category": "UMG" }, @@ -105,7 +135,55 @@ }, { "name": "apply_layout", - "description": "Applies a bulk layout definition (HTML/JSON).", + "description": "Applies a bulk layout definition (HTML/JSON). Use `apply_constrained_layout` for new UI unless you need raw JSON/HTML.", + "enabled": true, + "category": "UMG" + }, + { + "name": "apply_constrained_layout", + "description": "Applies a layout using unified DSL semantics. **PREFERRED** for all layout work. Full tree: `layout_type` (flex|grid|absolute), `direction`, `gap`, `align_items`, `justify_content`, children[]. Patch existing widgets: `{\\\"patch\\\": true, \\\"updates\\\": [{\\\"name\\\": \\\"WidgetA\\\", \\\"size\\\": \\\"fill|auto\\\", \\\"fill_weight\\\": 1, \\\"h_align\\\": \\\"Left|Center|Right|Fill\\\", \\\"v_align\\\": \\\"Top|Center|Bottom|Fill\\\"}]}`. Unified fields (never raw Slot JSON): `size` fill/auto/100%, `h_align`, `v_align`, `width`/`height` 100%. Canvas only under layout_type absolute; use `position` or h/v_align for corners. Legacy: `flex_grow`, `align`, `justify` still supported.", + "enabled": true, + "category": "UMG" + }, + { + "name": "theme_get", + "description": "Reads design tokens from `/Game/UI/theme.json` (falls back to plugin default). **Theme First**: call before setting colors/spacing/font sizes.", + "enabled": true, + "category": "UMG" + }, + { + "name": "theme_apply", + "description": "Incrementally merges a JSON patch into theme.json, saves to disk, and reloads the C++ token cache. Use instead of hardcoding new token values in widget properties.", + "enabled": true, + "category": "UMG" + }, + { + "name": "theme_resolve_token", + "description": "Resolves a single @token (e.g. `@color.primary`) against the active theme cache. Useful for debugging token values.", + "enabled": true, + "category": "UMG" + }, + { + "name": "catalog_list", + "description": "Lists reusable WidgetBlueprint components (WBP_* / tagged) under a content root with descriptions. **Catalog First**: prefer catalog matches over atomic Button/TextBlock.", + "enabled": true, + "category": "UMG" + }, + { + "name": "catalog_describe", + "description": "Loads a WBP and returns NamedSlot names plus Expose-on-Spawn variables for AI parameterization.", + "enabled": true, + "category": "UMG" + }, + { + "name": "patch_preview", + "description": "Dry-run RFC6902-style patch ops; returns planned mapping to existing tools without modifying the asset. Always preview multi-step edits first.", + "enabled": true, + "category": "UMG" + }, + { + "name": "patch_apply", + "description": "Applies patch ops atomically in one undo transaction. Ops: `set` -> set_widget_properties, `add` -> create_widget, `remove` -> eliminate widget (`/widgets/{Name}` only). Optional `expected_revision` from patch_preview prevents stale applies.", "enabled": true, "category": "UMG" }, diff --git a/Resources/storybook_catalog.json b/Resources/storybook_catalog.json new file mode 100644 index 0000000..90ee178 --- /dev/null +++ b/Resources/storybook_catalog.json @@ -0,0 +1,14 @@ +{ + "components": [ + { + "id": "example_button_row", + "asset_path": "/Game/UI/Examples/WBP_StorybookDemo", + "description": "Example catalog entry. Replace asset_path with your Widget Blueprint.", + "variants": [ + { "widget_name": "PrimaryButton", "label": "primary" }, + { "widget_name": "SecondaryButton", "label": "secondary" }, + { "widget_name": "DisabledButton", "label": "disabled" } + ] + } + ] +} diff --git a/Resources/theme_default.json b/Resources/theme_default.json new file mode 100644 index 0000000..d066bf4 --- /dev/null +++ b/Resources/theme_default.json @@ -0,0 +1,30 @@ +{ + "color": { + "primary": "0.1, 0.2, 0.8, 1.0", + "secondary": "0.2, 0.6, 0.9, 1.0", + "background": "0.02, 0.02, 0.05, 1.0", + "surface": "0.08, 0.08, 0.12, 1.0", + "text": "0.95, 0.95, 0.98, 1.0", + "textMuted": "0.6, 0.62, 0.68, 1.0", + "danger": "0.85, 0.15, 0.15, 1.0", + "success": "0.15, 0.75, 0.35, 1.0" + }, + "spacing": { + "xs": 4, + "sm": 8, + "md": 16, + "lg": 24, + "xl": 32 + }, + "fontSize": { + "caption": 12, + "body": 14, + "subtitle": 18, + "title": 24 + }, + "radius": { + "sm": 4, + "md": 8, + "lg": 12 + } +} diff --git a/Resources/umg_lint_rules.json b/Resources/umg_lint_rules.json new file mode 100644 index 0000000..0588a41 --- /dev/null +++ b/Resources/umg_lint_rules.json @@ -0,0 +1,12 @@ +{ + "depth_threshold": 10, + "naming_conventions": [ + { "class_suffix": "Button", "prefix": "Btn_" }, + { "class_suffix": "TextBlock", "prefix": "Txt_" }, + { "class_suffix": "Image", "prefix": "Img_" }, + { "class_suffix": "EditableTextBox", "prefix": "Edt_" }, + { "class_suffix": "CheckBox", "prefix": "Chk_" }, + { "class_suffix": "Slider", "prefix": "Sld_" }, + { "class_suffix": "ProgressBar", "prefix": "Prg_" } + ] +} diff --git a/Source/UmgMcp/Private/Animation/UmgMcpSequencerCommands.cpp b/Source/UmgMcp/Private/Animation/UmgMcpSequencerCommands.cpp index d8eae21..8426091 100644 --- a/Source/UmgMcp/Private/Animation/UmgMcpSequencerCommands.cpp +++ b/Source/UmgMcp/Private/Animation/UmgMcpSequencerCommands.cpp @@ -1307,7 +1307,7 @@ TSharedPtr FUmgMcpSequencerCommands::AppendTimeSlice(const TSharedP int32 WidgetKeys = 0; for (const auto& PropertyPair : PropertiesObj->Values) { - FString PropertyName = PropertyPair.Key; + FString PropertyName(PropertyPair.Key.ToView()); TSharedPtr KeyObj = MakeShared(); KeyObj->SetNumberField(TEXT("time"), TimeSeconds); KeyObj->SetField(TEXT("value"), PropertyPair.Value); diff --git a/Source/UmgMcp/Private/Bridge/MCPServerRunnable.cpp b/Source/UmgMcp/Private/Bridge/MCPServerRunnable.cpp index f83e58e..33e024f 100644 --- a/Source/UmgMcp/Private/Bridge/MCPServerRunnable.cpp +++ b/Source/UmgMcp/Private/Bridge/MCPServerRunnable.cpp @@ -13,6 +13,97 @@ #include "Misc/ScopeLock.h" #include "HAL/PlatformTime.h" +namespace MCPServerTransport +{ + static bool SendAllBytes(const TSharedPtr& Socket, const uint8* Data, int32 TotalBytes) + { + if (!Socket.IsValid() || TotalBytes <= 0) + { + return true; + } + + int32 TotalSent = 0; + while (TotalSent < TotalBytes) + { + int32 BytesSent = 0; + if (!Socket->Send(Data + TotalSent, TotalBytes - TotalSent, BytesSent)) + { + UE_LOG(LogUmgMcp, Error, TEXT("MCPServerRunnable: Socket send failed after %d/%d bytes"), TotalSent, TotalBytes); + return false; + } + + if (BytesSent <= 0) + { + UE_LOG(LogUmgMcp, Error, TEXT("MCPServerRunnable: Socket send returned 0 bytes (%d/%d)"), TotalSent, TotalBytes); + return false; + } + + TotalSent += BytesSent; + } + + return true; + } + + static void RedactImageField(TSharedPtr& JsonObject, const TCHAR* FieldName) + { + if (!JsonObject.IsValid() || !JsonObject->HasField(FieldName)) + { + return; + } + + FString Base64Value; + if (!JsonObject->TryGetStringField(FieldName, Base64Value)) + { + return; + } + + JsonObject->RemoveField(FieldName); + JsonObject->SetNumberField(TEXT("image_base64_bytes"), Base64Value.Len()); + } + + static FString SummarizeResponseForLog(const FString& Response) + { + TSharedPtr JsonObject; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(Response); + if (!FJsonSerializer::Deserialize(Reader, JsonObject) || !JsonObject.IsValid()) + { + return FString::Printf(TEXT(""), Response.Len()); + } + + RedactImageField(JsonObject, TEXT("image_base64")); + + const TArray>* RendersArray = nullptr; + if (JsonObject->TryGetArrayField(TEXT("renders"), RendersArray)) + { + TArray> RedactedRenders; + for (const TSharedPtr& RenderValue : *RendersArray) + { + const TSharedPtr* RenderObjectPtr = nullptr; + if (RenderValue->TryGetObject(RenderObjectPtr) && RenderObjectPtr && RenderObjectPtr->IsValid()) + { + TSharedPtr RenderCopy = MakeShared(); + for (const auto& Field : (*RenderObjectPtr)->Values) + { + RenderCopy->SetField(Field.Key.ToView(), Field.Value); + } + RedactImageField(RenderCopy, TEXT("image_base64")); + RedactedRenders.Add(MakeShared(RenderCopy)); + } + else + { + RedactedRenders.Add(RenderValue); + } + } + JsonObject->SetArrayField(TEXT("renders"), RedactedRenders); + } + + FString Summary; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Summary); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + return Summary; + } +} + FMCPServerRunnable::FMCPServerRunnable(UUmgMcpBridge* InBridge, TSharedPtr InListenerSocket) : Bridge(InBridge) , ListenerSocket(InListenerSocket) @@ -193,21 +284,30 @@ void FMCPServerRunnable::ProcessMessage(TSharedPtr Client, const FStrin // Execute command FString Response = Bridge->ExecuteCommand(CommandType, Params); - // Send response - int32 BytesSent = 0; - - // Convert to UTF8 + // Send response (loop until all UTF-8 bytes and delimiter are sent) FTCHARToUTF8 Utf8Response(*Response); - - // Send the string content - if (Utf8Response.Length() > 0) + const int32 ResponseByteCount = Utf8Response.Length(); + + if (ResponseByteCount > 0) { - Client->Send((const uint8*)Utf8Response.Get(), Utf8Response.Length(), BytesSent); + if (!MCPServerTransport::SendAllBytes(Client, reinterpret_cast(Utf8Response.Get()), ResponseByteCount)) + { + UE_LOG(LogUmgMcp, Error, TEXT("MCPServerRunnable: Failed to send full response body (%d bytes)"), ResponseByteCount); + return; + } } - - // Send the null delimiter - uint8 Delimiter = 0; - Client->Send(&Delimiter, 1, BytesSent); - - UE_LOG(LogUmgMcp, Display, TEXT("[UMGMCP-Message] Sent response: %s"), *Response); + + const uint8 Delimiter = 0; + if (!MCPServerTransport::SendAllBytes(Client, &Delimiter, 1)) + { + UE_LOG(LogUmgMcp, Error, TEXT("MCPServerRunnable: Failed to send response delimiter")); + return; + } + + UE_LOG( + LogUmgMcp, + Display, + TEXT("[UMGMCP-Message] Sent response (%d utf8 bytes): %s"), + ResponseByteCount, + *MCPServerTransport::SummarizeResponseForLog(Response)); } \ No newline at end of file diff --git a/Source/UmgMcp/Private/Bridge/UmgMcpBridge.cpp b/Source/UmgMcp/Private/Bridge/UmgMcpBridge.cpp index 825fbfb..4d83697 100644 --- a/Source/UmgMcp/Private/Bridge/UmgMcpBridge.cpp +++ b/Source/UmgMcp/Private/Bridge/UmgMcpBridge.cpp @@ -60,6 +60,7 @@ #include "Material/UmgMcpMaterialCommands.h" // Add Material Commands #include "Blueprint/UmgBlueprintFunctionSubsystem.h" #include "FileManage/UmgAttentionSubsystem.h" +#include "Orchestration/UmgMcpOrchestrationCommands.h" @@ -72,6 +73,8 @@ UUmgMcpBridge::UUmgMcpBridge() BlueprintCommands = MakeShared(); SequencerCommands = MakeShared(); MaterialCommands = MakeShared(); + StorybookCommands = MakeShared(); + OrchestrationCommands = MakeShared(); } UUmgMcpBridge::~UUmgMcpBridge() @@ -83,6 +86,8 @@ UUmgMcpBridge::~UUmgMcpBridge() BlueprintCommands.Reset(); SequencerCommands.Reset(); MaterialCommands.Reset(); + StorybookCommands.Reset(); + OrchestrationCommands.Reset(); } // Initialize subsystem @@ -424,10 +429,12 @@ FString UUmgMcpBridge::InternalExecuteCommand(const FString& CommandType, const CommandType == TEXT("query_widget_properties") || CommandType == TEXT("get_layout_data") || CommandType == TEXT("check_widget_overlap") || + CommandType == TEXT("lint_umg_asset") || CommandType == TEXT("create_widget") || CommandType == TEXT("set_widget_properties") || CommandType == TEXT("delete_widget") || CommandType == TEXT("reparent_widget") || + CommandType == TEXT("move_widget") || CommandType == TEXT("save_asset") || CommandType == TEXT("get_widget_schema")) { @@ -464,6 +471,17 @@ FString UUmgMcpBridge::InternalExecuteCommand(const FString& CommandType, const { ResultJson = SequencerCommands->HandleCommand(CommandType, Params); } + // Orchestration Commands (Theme / Catalog / Patch) + else if (CommandType == TEXT("theme_get") || + CommandType == TEXT("theme_apply") || + CommandType == TEXT("theme_resolve_token") || + CommandType == TEXT("catalog_list") || + CommandType == TEXT("catalog_describe") || + CommandType == TEXT("patch_apply") || + CommandType == TEXT("get_patch_revision")) + { + ResultJson = OrchestrationCommands->HandleCommand(CommandType, Params); + } // Editor Commands (Actors, Level, etc.) else if (CommandType == TEXT("get_actors_in_level") || CommandType == TEXT("find_actors_by_name") || @@ -522,7 +540,7 @@ FString UUmgMcpBridge::InternalExecuteCommand(const FString& CommandType, const // Copy existing fields for (auto& Elem : Params->Values) { - ModifiedParams->SetField(Elem.Key, Elem.Value); + ModifiedParams->SetField(Elem.Key.ToView(), Elem.Value); } FString SubAction; @@ -636,6 +654,13 @@ FString UUmgMcpBridge::InternalExecuteCommand(const FString& CommandType, const { ResultJson = MaterialCommands->HandleCommand(CommandType, Params); } + // Storybook / Preview Commands + else if (CommandType == TEXT("render_widget_preview") || + CommandType == TEXT("storybook_list_variants") || + CommandType == TEXT("storybook_render")) + { + ResultJson = StorybookCommands->HandleCommand(CommandType, Params); + } else { ResponseJson->SetStringField(TEXT("status"), TEXT("error")); @@ -681,7 +706,7 @@ FString UUmgMcpBridge::InternalExecuteCommand(const FString& CommandType, const ResponseJson->SetStringField(TEXT("status"), TEXT("success")); for (const auto& Field : ResultJson->Values) { - const FString& Key = Field.Key; + const FString Key(Field.Key.ToView()); if (Key != TEXT("success") && Key != TEXT("status")) { ResponseJson->SetField(Key, Field.Value); diff --git a/Source/UmgMcp/Private/Bridge/UmgMcpCommonUtils.cpp b/Source/UmgMcp/Private/Bridge/UmgMcpCommonUtils.cpp index 4d4f500..3a375ca 100644 --- a/Source/UmgMcp/Private/Bridge/UmgMcpCommonUtils.cpp +++ b/Source/UmgMcp/Private/Bridge/UmgMcpCommonUtils.cpp @@ -50,7 +50,7 @@ TSharedPtr FUmgMcpCommonUtils::CreateSuccessResponse(const TSharedP // Merge data fields directly instead of nesting under "data" for (const auto& Field : Data->Values) { - ResponseObject->SetField(Field.Key, Field.Value); + ResponseObject->SetField(Field.Key.ToView(), Field.Value); } } @@ -849,4 +849,4 @@ UWidgetBlueprint* FUmgMcpCommonUtils::GetTargetWidgetBlueprint(const TSharedPtr< OutError = FString::Printf(TEXT("Failed to load UMG asset from specified path: %s"), *AssetPath); return nullptr; } -} \ No newline at end of file +} diff --git a/Source/UmgMcp/Private/Catalog/UmgCatalogSubsystem.cpp b/Source/UmgMcp/Private/Catalog/UmgCatalogSubsystem.cpp new file mode 100644 index 0000000..f83bec2 --- /dev/null +++ b/Source/UmgMcp/Private/Catalog/UmgCatalogSubsystem.cpp @@ -0,0 +1,178 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#include "Catalog/UmgCatalogSubsystem.h" + +#include "AssetRegistry/AssetRegistryModule.h" +#include "Blueprint/WidgetTree.h" +#include "Components/NamedSlot.h" +#include "Components/PanelWidget.h" +#include "EditorAssetLibrary.h" +#include "Engine/BlueprintGeneratedClass.h" +#include "Kismet2/BlueprintEditorUtils.h" +#include "Misc/PackageName.h" +#include "Serialization/JsonWriter.h" +#include "Serialization/JsonSerializer.h" +#include "WidgetBlueprint.h" + +DEFINE_LOG_CATEGORY(LogUmgCatalog); + +void UUmgCatalogSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + UE_LOG(LogUmgCatalog, Log, TEXT("UmgCatalogSubsystem initialized.")); +} + +FString UUmgCatalogSubsystem::ResolveWidgetBlueprintPath(const FString& AssetPath) const +{ + if (AssetPath.IsEmpty()) + { + return FString(); + } + + FString Normalized = AssetPath; + Normalized.ReplaceInline(TEXT("\\"), TEXT("/")); + + if (Normalized.EndsWith(TEXT("_C"))) + { + return Normalized; + } + + if (Normalized.Contains(TEXT("."))) + { + const FString PackagePath = FPaths::GetPath(Normalized); + const FString AssetName = FPaths::GetBaseFilename(Normalized); + return FString::Printf(TEXT("%s.%s_C"), *PackagePath, *AssetName); + } + + const FString AssetName = FPaths::GetBaseFilename(Normalized); + return FString::Printf(TEXT("%s.%s_C"), *Normalized, *AssetName); +} + +FString UUmgCatalogSubsystem::ListComponents(const FString& RootPath, bool bRecursive) const +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); + IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); + + FARFilter Filter; + Filter.ClassPaths.Add(UWidgetBlueprint::StaticClass()->GetClassPathName()); + Filter.bRecursivePaths = bRecursive; + + FString PackageRoot = RootPath; + PackageRoot.ReplaceInline(TEXT("\\"), TEXT("/")); + if (!PackageRoot.StartsWith(TEXT("/"))) + { + PackageRoot = TEXT("/Game/") + PackageRoot; + } + Filter.PackagePaths.Add(FName(*PackageRoot)); + + TArray Assets; + AssetRegistry.GetAssets(Filter, Assets); + + TArray> CatalogArray; + for (const FAssetData& Asset : Assets) + { + const FString AssetName = Asset.AssetName.ToString(); + const FString CategoryTag = Asset.GetTagValueRef(TEXT("UmgCatalogCategory")); + const bool bIsPrefixed = AssetName.StartsWith(TEXT("WBP_")) || AssetName.StartsWith(TEXT("UW_")); + const bool bIsTagged = !CategoryTag.IsEmpty(); + if (!bIsPrefixed && !bIsTagged) + { + continue; + } + + FString Description = Asset.GetTagValueRef(TEXT("BlueprintDescription")); + if (Description.IsEmpty()) + { + Description = TEXT("No description"); + } + + FString CategoryValue = CategoryTag; + if (CategoryValue.IsEmpty()) + { + CategoryValue = TEXT("component"); + } + + const FString PackageName = Asset.PackageName.ToString(); + const FString ClassPath = FString::Printf(TEXT("%s.%s_C"), *PackageName, *AssetName); + + TSharedPtr Entry = MakeShared(); + Entry->SetStringField(TEXT("name"), AssetName); + Entry->SetStringField(TEXT("path"), ClassPath); + Entry->SetStringField(TEXT("package"), PackageName); + Entry->SetStringField(TEXT("description"), Description); + Entry->SetStringField(TEXT("category"), CategoryValue); + CatalogArray.Add(MakeShared(Entry)); + } + + TSharedPtr Result = MakeShared(); + Result->SetStringField(TEXT("root"), PackageRoot); + Result->SetNumberField(TEXT("count"), CatalogArray.Num()); + Result->SetArrayField(TEXT("components"), CatalogArray); + + FString Out; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Out); + FJsonSerializer::Serialize(Result.ToSharedRef(), Writer); + return Out; +} + +FString UUmgCatalogSubsystem::DescribeComponent(const FString& AssetPath) const +{ + const FString ClassPath = ResolveWidgetBlueprintPath(AssetPath); + + FString ObjectPath = AssetPath; + ObjectPath.ReplaceInline(TEXT("\\"), TEXT("/")); + if (!ObjectPath.Contains(TEXT("."))) + { + ObjectPath = FString::Printf(TEXT("%s.%s"), *ObjectPath, *FPaths::GetBaseFilename(ObjectPath)); + } + + UWidgetBlueprint* WidgetBP = LoadObject(nullptr, *ObjectPath); + + TSharedPtr Result = MakeShared(); + Result->SetStringField(TEXT("path"), ClassPath); + + if (!WidgetBP) + { + Result->SetBoolField(TEXT("success"), false); + Result->SetStringField(TEXT("error"), FString::Printf(TEXT("Failed to load WidgetBlueprint: %s"), *AssetPath)); + } + else + { + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("name"), WidgetBP->GetName()); + + TArray> SlotsArray; + TArray> ParamsArray; + + if (WidgetBP->WidgetTree) + { + TArray AllWidgets; + WidgetBP->WidgetTree->GetAllWidgets(AllWidgets); + for (UWidget* Widget : AllWidgets) + { + if (Cast(Widget)) + { + SlotsArray.Add(MakeShared(Widget->GetName())); + } + } + } + + for (const FBPVariableDescription& Variable : WidgetBP->NewVariables) + { + if (Variable.PropertyFlags & CPF_ExposeOnSpawn) + { + TSharedPtr ParamObj = MakeShared(); + ParamObj->SetStringField(TEXT("name"), Variable.VarName.ToString()); + ParamObj->SetStringField(TEXT("type"), Variable.VarType.PinCategory.ToString()); + ParamsArray.Add(MakeShared(ParamObj)); + } + } + + Result->SetArrayField(TEXT("slots"), SlotsArray); + Result->SetArrayField(TEXT("params"), ParamsArray); + } + + FString Out; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Out); + FJsonSerializer::Serialize(Result.ToSharedRef(), Writer); + return Out; +} diff --git a/Source/UmgMcp/Private/FileManage/UmgAttentionSubsystem.cpp b/Source/UmgMcp/Private/FileManage/UmgAttentionSubsystem.cpp index 91c58a6..ceb1804 100644 --- a/Source/UmgMcp/Private/FileManage/UmgAttentionSubsystem.cpp +++ b/Source/UmgMcp/Private/FileManage/UmgAttentionSubsystem.cpp @@ -379,3 +379,10 @@ void UUmgAttentionSubsystem::SetCursorPosition(const FVector2D& NewPosition) { CurrentNodePosition = NewPosition; } + +int32 UUmgAttentionSubsystem::IncrementPatchRevision() +{ + ++PatchRevisionCounter; + UE_LOG(LogUmgAttention, Log, TEXT("Patch revision incremented to %d"), PatchRevisionCounter); + return PatchRevisionCounter; +} diff --git a/Source/UmgMcp/Private/FileManage/UmgFileTransformation.cpp b/Source/UmgMcp/Private/FileManage/UmgFileTransformation.cpp index 95c14cc..c557a73 100644 --- a/Source/UmgMcp/Private/FileManage/UmgFileTransformation.cpp +++ b/Source/UmgMcp/Private/FileManage/UmgFileTransformation.cpp @@ -9,6 +9,9 @@ #include "Blueprint/WidgetTree.h" #include "Components/PanelWidget.h" #include "Components/PanelSlot.h" +#include "Components/CanvasPanelSlot.h" +#include "Components/VerticalBox.h" +#include "Async/Async.h" #include "Components/TextBlock.h" #include "JsonObjectConverter.h" #include "Serialization/JsonWriter.h" @@ -20,14 +23,295 @@ #include "Async/Async.h" #include "WidgetBlueprintFactory.h" #include "AssetRegistry/AssetRegistryModule.h" -#include "Kismet2/BlueprintEditorUtils.h" - +#include "Preview/UmgPreviewRenderUtils.h" +#include "Widget/UmgMcpPropertyJsonUtils.h" // Forward declarations static UWidget* CreateWidgetFromJson(const TSharedPtr& WidgetJson, UWidgetTree* WidgetTree, UWidget* ParentWidget); static void ApplyPropertiesToExistingWidget(const TSharedPtr& WidgetJson, UWidget* TargetWidget); static void MergeChildrenAppendOnly(const TSharedPtr& WidgetJson, UWidgetTree* WidgetTree, UWidget* TargetWidget); static void UpsertWidgetFromJsonAppendOnly(const TSharedPtr& WidgetJson, UWidgetTree* WidgetTree, UWidget* TargetWidget); +/** Extract slot properties from properties.Slot and/or top-level SlotData. */ +static TSharedPtr ExtractSlotPropsFromWidgetJson(const TSharedPtr& WidgetJson) +{ + if (!WidgetJson.IsValid()) + { + return nullptr; + } + + TSharedPtr SlotProps = nullptr; + + const TSharedPtr* SlotDataPtr = nullptr; + if (WidgetJson->TryGetObjectField(TEXT("SlotData"), SlotDataPtr) || + WidgetJson->TryGetObjectField(TEXT("slot_data"), SlotDataPtr)) + { + SlotProps = MakeShared(*(*SlotDataPtr)); + } + + const TSharedPtr* PropertiesJsonObjPtr = nullptr; + if (WidgetJson->TryGetObjectField(TEXT("properties"), PropertiesJsonObjPtr)) + { + const TSharedPtr* SlotObjPtr = nullptr; + if ((*PropertiesJsonObjPtr)->TryGetObjectField(TEXT("Slot"), SlotObjPtr)) + { + if (!SlotProps.IsValid()) + { + SlotProps = MakeShared(*(*SlotObjPtr)); + } + else + { + for (const auto& Pair : (*SlotObjPtr)->Values) + { + SlotProps->SetField(Pair.Key, Pair.Value); + } + } + } + } + + return SlotProps; +} + +/** Apply slot JSON to the widget's current UPanelSlot with parent-type validation. */ +static bool ApplySlotPropertiesToWidget(UWidget* Widget, const TSharedPtr& SlotProps, FString* OutRejectedKeys = nullptr) +{ + if (!Widget || !SlotProps.IsValid() || !Widget->Slot) + { + return true; + } + + UPanelSlot* Slot = Widget->Slot; + TSharedPtr NormalizedSlotProps = UUmgFileTransformation::NormalizeJsonKeysToPascalCase(SlotProps); + + // Canvas aliases: Size[w,h] -> LayoutData.Offsets.Right/Bottom; Anchors -> LayoutData.Anchors + if (UCanvasPanelSlot* CanvasSlot = Cast(Slot)) + { + TSharedPtr SizeVal = NormalizedSlotProps->TryGetField(TEXT("Size")); + if (SizeVal.IsValid() && SizeVal->Type == EJson::Array) + { + const TArray>& Arr = SizeVal->AsArray(); + if (Arr.Num() >= 2) + { + TSharedPtr LayoutData = NormalizedSlotProps->HasField(TEXT("LayoutData")) + ? MakeShared(*NormalizedSlotProps->GetObjectField(TEXT("LayoutData"))) + : MakeShared(); + + TSharedPtr Offsets = LayoutData->HasField(TEXT("Offsets")) + ? MakeShared(*LayoutData->GetObjectField(TEXT("Offsets"))) + : MakeShared(); + + if (!Offsets->HasField(TEXT("Left"))) { Offsets->SetNumberField(TEXT("Left"), 0.0); } + if (!Offsets->HasField(TEXT("Top"))) { Offsets->SetNumberField(TEXT("Top"), 0.0); } + Offsets->SetNumberField(TEXT("Right"), Arr[0]->AsNumber()); + Offsets->SetNumberField(TEXT("Bottom"), Arr[1]->AsNumber()); + LayoutData->SetObjectField(TEXT("Offsets"), Offsets); + NormalizedSlotProps->SetObjectField(TEXT("LayoutData"), LayoutData); + NormalizedSlotProps->RemoveField(TEXT("Size")); + } + } + + if (NormalizedSlotProps->HasField(TEXT("Anchors"))) + { + TSharedPtr LayoutData = NormalizedSlotProps->HasField(TEXT("LayoutData")) + ? MakeShared(*NormalizedSlotProps->GetObjectField(TEXT("LayoutData"))) + : MakeShared(); + LayoutData->SetObjectField(TEXT("Anchors"), NormalizedSlotProps->GetObjectField(TEXT("Anchors"))); + NormalizedSlotProps->SetObjectField(TEXT("LayoutData"), LayoutData); + NormalizedSlotProps->RemoveField(TEXT("Anchors")); + } + } + + // Whitelist filter per slot class + static TMap> AllowedSlotKeys; + if (AllowedSlotKeys.Num() == 0) + { + AllowedSlotKeys.Add(TEXT("VerticalBoxSlot"), {"Size", "Padding", "HorizontalAlignment", "VerticalAlignment"}); + AllowedSlotKeys.Add(TEXT("HorizontalBoxSlot"), {"Size", "Padding", "HorizontalAlignment", "VerticalAlignment"}); + AllowedSlotKeys.Add(TEXT("CanvasPanelSlot"), {"LayoutData", "Alignment", "AutoSize", "ZOrder"}); + AllowedSlotKeys.Add(TEXT("GridSlot"), {"Row", "Column", "RowSpan", "ColumnSpan", "HorizontalAlignment", "VerticalAlignment", "Padding"}); + AllowedSlotKeys.Add(TEXT("OverlaySlot"), {"HorizontalAlignment", "VerticalAlignment", "Padding"}); + AllowedSlotKeys.Add(TEXT("ScrollBoxSlot"), {"Padding", "HorizontalAlignment", "VerticalAlignment"}); + AllowedSlotKeys.Add(TEXT("WrapBoxSlot"), {"Padding", "FillEmptySpace", "HorizontalAlignment", "VerticalAlignment"}); + } + + const FString SlotClassName = Slot->GetClass()->GetName(); + if (const TSet* Allowed = AllowedSlotKeys.Find(SlotClassName)) + { + TSharedPtr Filtered = MakeShared(); + TArray Rejected; + for (const auto& Pair : NormalizedSlotProps->Values) + { + const FString Key(Pair.Key.ToView()); + if (Allowed->Contains(Key)) + { + Filtered->SetField(Pair.Key, Pair.Value); + } + else + { + Rejected.Add(Key); + } + } + if (Rejected.Num() > 0) + { + UE_LOG(LogUmgMcp, Warning, TEXT("ApplySlotProperties: Rejected keys for '%s' (%s): %s"), + *Widget->GetName(), *SlotClassName, *FString::Join(Rejected, TEXT(", "))); + if (OutRejectedKeys) + { + *OutRejectedKeys = FString::Join(Rejected, TEXT(", ")); + } + } + NormalizedSlotProps = Filtered; + } + + const bool bHasCanvasOnly = + NormalizedSlotProps->HasField(TEXT("Anchors")) || + NormalizedSlotProps->HasField(TEXT("LayoutData")) || + NormalizedSlotProps->HasField(TEXT("Position")); + + if (bHasCanvasOnly && !Cast(Slot)) + { + UE_LOG(LogUmgMcp, Warning, + TEXT("ApplySlotProperties: Discarding canvas-only keys for '%s' (slot type: %s)."), + *Widget->GetName(), *Slot->GetClass()->GetName()); + + TSharedPtr Filtered = MakeShared(); + for (const auto& Pair : NormalizedSlotProps->Values) + { + const FString Key(Pair.Key.ToView()); + if (Key != TEXT("Anchors") && Key != TEXT("LayoutData") && Key != TEXT("Position") && Key != TEXT("Alignment")) + { + Filtered->SetField(Pair.Key, Pair.Value); + } + } + NormalizedSlotProps = Filtered; + } + + if (NormalizedSlotProps->Values.Num() == 0) + { + return true; + } + + Slot->Modify(); + if (!FJsonObjectConverter::JsonObjectToUStruct(NormalizedSlotProps.ToSharedRef(), Slot->GetClass(), Slot, 0, 0)) + { + UE_LOG(LogUmgMcp, Warning, + TEXT("ApplySlotProperties: Issues applying slot properties to '%s' (slot: %s)."), + *Widget->GetName(), *Slot->GetClass()->GetName()); + return false; + } + return true; +} + +static FApplyJsonToUmgResult MakeApplyError(const FString& Message) +{ + FApplyJsonToUmgResult Result; + Result.bSuccess = false; + Result.bApplied = false; + Result.ErrorMessage = Message; + return Result; +} + +static FApplyJsonToUmgResult MakeApplySuccess(TArray&& Reparented = {}) +{ + FApplyJsonToUmgResult Result; + Result.bSuccess = true; + Result.bApplied = true; + Result.ReparentedWidgets = MoveTemp(Reparented); + return Result; +} + +struct FDeferredReparent +{ + FString WidgetName; + FString ExpectedParentName; +}; + +static bool ExecuteReparent(UWidgetBlueprint* WidgetBlueprint, const FString& WidgetName, const FString& ExpectedParentName, FString& OutError) +{ + UWidget* WidgetToMove = WidgetBlueprint->WidgetTree->FindWidget(FName(*WidgetName)); + if (!WidgetToMove) + { + OutError = FString::Printf(TEXT("Widget '%s' not found for reparent."), *WidgetName); + return false; + } + + UPanelWidget* NewParent = Cast(WidgetBlueprint->WidgetTree->FindWidget(FName(*ExpectedParentName))); + if (!NewParent) + { + OutError = FString::Printf(TEXT("Expected parent '%s' not found for widget '%s'."), *ExpectedParentName, *WidgetName); + return false; + } + + if (WidgetToMove->GetParent() == NewParent) + { + return true; + } + + WidgetBlueprint->Modify(); + if (UPanelWidget* OldParent = WidgetToMove->GetParent()) + { + OldParent->RemoveChild(WidgetToMove); + } + NewParent->AddChild(WidgetToMove); + return true; +} + +static bool IsSingleChildPanelWidget(UWidget* Widget) +{ + if (!Widget) + { + return false; + } + const FName ClassName = Widget->GetClass()->GetFName(); + return ClassName == FName(TEXT("Button")) || ClassName == FName(TEXT("Border")) || ClassName == FName(TEXT("SizeBox")); +} + +static UPanelWidget* EnsureSingleChildCapacity(UPanelWidget* ParentPanel, UWidgetTree* WidgetTree) +{ + if (!ParentPanel || !WidgetTree || !IsSingleChildPanelWidget(ParentPanel)) + { + return ParentPanel; + } + + if (ParentPanel->GetChildrenCount() == 0) + { + return ParentPanel; + } + + if (UVerticalBox* ExistingVBox = Cast(ParentPanel->GetChildAt(0))) + { + return ExistingVBox; + } + + WidgetTree->Modify(); + ParentPanel->Modify(); + + const FString WrapperName = ParentPanel->GetName() + TEXT("_Wrap"); + UVerticalBox* Wrapper = WidgetTree->ConstructWidget(UVerticalBox::StaticClass(), *WrapperName); + if (!Wrapper) + { + UE_LOG(LogUmgMcp, Warning, TEXT("EnsureSingleChildCapacity: Failed to create wrapper for '%s'."), *ParentPanel->GetName()); + return ParentPanel; + } + + TArray ChildrenToMove; + for (int32 Idx = 0; Idx < ParentPanel->GetChildrenCount(); ++Idx) + { + ChildrenToMove.Add(ParentPanel->GetChildAt(Idx)); + } + + for (UWidget* Child : ChildrenToMove) + { + ParentPanel->RemoveChild(Child); + Wrapper->AddChild(Child); + } + + ParentPanel->AddChild(Wrapper); + UE_LOG(LogUmgMcp, Log, TEXT("EnsureSingleChildCapacity: Wrapped %d children of '%s' into '%s'."), + ChildrenToMove.Num(), *ParentPanel->GetName(), *WrapperName); + + return Wrapper; +} + struct FJsonWidgetInfo { FString Name; @@ -63,7 +347,7 @@ static void CollectJsonWidgetInfos(const TSharedPtr& JsonNode, cons } } -bool ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& JsonData, const FString& TargetWidgetName); +FApplyJsonToUmgResult ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& JsonData, const FString& TargetWidgetName); TSharedPtr UUmgFileTransformation::NormalizeJsonKeysToPascalCase(const TSharedPtr& SourceJson) { @@ -76,9 +360,9 @@ TSharedPtr UUmgFileTransformation::NormalizeJsonKeysToPascalCase(co for (const auto& Pair : SourceJson->Values) { - FString OriginalKey = Pair.Key; + FString OriginalKey(Pair.Key.ToView()); FString NormalizedKey; - + // Handle dotted keys (e.g. "slot.position") if (OriginalKey.Contains(TEXT("."))) { @@ -99,7 +383,7 @@ TSharedPtr UUmgFileTransformation::NormalizeJsonKeysToPascalCase(co { UE_LOG(LogUmgMcp, Verbose, TEXT("NormalizeJsonKeys: '%s' → '%s'"), *OriginalKey, *NormalizedKey); } - + // Recursively process nested objects and arrays TSharedPtr Value = Pair.Value; if (Value->Type == EJson::Object) @@ -174,46 +458,14 @@ TSharedPtr UUmgFileTransformation::ExportWidgetToJson(UWidget* Widg { if (Property->GetFName() == TEXT("Slot")) { - if (UPanelSlot* SlotObject = Cast(CastField(Property)->GetObjectPropertyValue_InContainer(Widget))) + if (TSharedPtr SlotPropertiesJson = FUmgMcpPropertyJsonUtils::SerializePanelSlotProperties(Widget->Slot)) { - TSharedPtr SlotPropertiesJson = MakeShared(); - UObject* DefaultSlotObject = SlotObject->GetClass()->GetDefaultObject(); - - for (TFieldIterator SlotPropIt(SlotObject->GetClass()); SlotPropIt; ++SlotPropIt) - { - FProperty* SlotProperty = *SlotPropIt; - FName SlotPropertyName = SlotProperty->GetFName(); - - if (SlotPropertyName == TEXT("Content") || SlotPropertyName == TEXT("Parent")) - { - continue; - } - - if (SlotProperty->HasAnyPropertyFlags(CPF_Edit) && !SlotProperty->HasAnyPropertyFlags(CPF_Transient)) - { - void* SlotValuePtr = SlotProperty->ContainerPtrToValuePtr(SlotObject); - void* DefaultSlotValuePtr = SlotProperty->ContainerPtrToValuePtr(DefaultSlotObject); - - if (!SlotProperty->Identical(SlotValuePtr, DefaultSlotValuePtr)) - { - TSharedPtr SlotPropertyJsonValue = FJsonObjectConverter::UPropertyToJsonValue(SlotProperty, SlotValuePtr); - if (SlotPropertyJsonValue.IsValid()) - { - SlotPropertiesJson->SetField(SlotProperty->GetName(), SlotPropertyJsonValue); - } - } - } - } - - if(SlotPropertiesJson->Values.Num() > 0) - { - PropertiesJson->SetObjectField(TEXT("Slot"), SlotPropertiesJson); - } + PropertiesJson->SetObjectField(TEXT("Slot"), SlotPropertiesJson); } } else { - TSharedPtr PropertyJsonValue = FJsonObjectConverter::UPropertyToJsonValue(Property, ValuePtr); + TSharedPtr PropertyJsonValue = FUmgMcpPropertyJsonUtils::PropertyToJsonValue(Property, ValuePtr); if (PropertyJsonValue.IsValid()) { PropertiesJson->SetField(Property->GetName(), PropertyJsonValue); @@ -315,25 +567,36 @@ FString UUmgFileTransformation::ExportUmgAssetToJsonString(const FString& AssetP } } -bool UUmgFileTransformation::ApplyJsonStringToUmgAsset(const FString& AssetPath, const FString& JsonData, const FString& TargetWidgetName) +FApplyJsonToUmgResult UUmgFileTransformation::ApplyJsonStringToUmgAsset(const FString& AssetPath, const FString& JsonData, const FString& TargetWidgetName) { - // Dispatch the task to the game thread asynchronously ("fire and forget"). - // The original implementation used a blocking wait which could cause the editor to freeze or deadlock. - // We capture parameters by value to ensure they are valid when the task eventually executes. - FFunctionGraphTask::CreateAndDispatchWhenReady([AssetPath, JsonData, TargetWidgetName]() + if (IsInGameThread()) { - ApplyJsonToUmgAsset_GameThread(AssetPath, JsonData, TargetWidgetName); - }, TStatId(), nullptr, ENamedThreads::GameThread); + return ApplyJsonToUmgAsset_GameThread(AssetPath, JsonData, TargetWidgetName); + } - // Return true to indicate the task was successfully dispatched. - // The operation itself runs in the background and the result will be visible in the editor. - return true; + TPromise Promise; + TFuture Future = Promise.GetFuture(); + + AsyncTask(ENamedThreads::GameThread, [AssetPath, JsonData, TargetWidgetName, Promise = MoveTemp(Promise)]() mutable + { + Promise.SetValue(ApplyJsonToUmgAsset_GameThread(AssetPath, JsonData, TargetWidgetName)); + }); + + if (Future.WaitFor(FTimespan::FromSeconds(60.0))) + { + return Future.Get(); + } + + return MakeApplyError(TEXT("Apply JSON timed out waiting for GameThread (60s).")); } // This function is executed on the game thread to ensure thread safety when dealing with UObjects. -bool ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& JsonData, const FString& TargetWidgetName) +FApplyJsonToUmgResult ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& JsonData, const FString& TargetWidgetName) { + TArray ReparentedWidgets; + TArray PendingReparents; + // 0. Handle default workspace: if AssetPath is empty, try to get target from attention subsystem FString FinalAssetPath = AssetPath; if (FinalAssetPath.IsEmpty() || FinalAssetPath.TrimStartAndEnd().IsEmpty()) @@ -345,7 +608,7 @@ bool ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& Jso FinalAssetPath = AttentionSubsystem->GetTargetUmgAsset(); } } - + if (FinalAssetPath.IsEmpty() || FinalAssetPath.TrimStartAndEnd().IsEmpty()) { FinalAssetPath = TEXT("/Game/UnrealMotionGraphicsMCP.UnrealMotionGraphicsMCP"); @@ -366,7 +629,7 @@ bool ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& Jso if (!FJsonSerializer::Deserialize(Reader, RootJsonObject) || !RootJsonObject.IsValid()) { UE_LOG(LogUmgMcp, Error, TEXT("ApplyJsonToUmgAsset_GameThread: Failed to parse JSON data.")); - return false; + return MakeApplyError(TEXT("Failed to parse JSON data.")); } UE_LOG(LogUmgMcp, Log, TEXT("ApplyJsonToUmgAsset_GameThread: JSON data parsed successfully.")); @@ -392,10 +655,9 @@ bool ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& Jso if (!Package) { UE_LOG(LogUmgMcp, Error, TEXT("ApplyJsonToUmgAsset_GameThread: Failed to create package at '%s'."), *PackagePath); - return false; + return MakeApplyError(FString::Printf(TEXT("Failed to create package at '%s'."), *PackagePath)); } - - // Create the Widget Blueprint using the factory + UWidgetBlueprintFactory* Factory = NewObject(); WidgetBlueprint = Cast(Factory->FactoryCreateNew( UWidgetBlueprint::StaticClass(), @@ -405,25 +667,24 @@ bool ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& Jso nullptr, GWarn )); - + if (!WidgetBlueprint) { UE_LOG(LogUmgMcp, Error, TEXT("ApplyJsonToUmgAsset_GameThread: Failed to create new Widget Blueprint at '%s'."), *FinalAssetPath); - return false; + return MakeApplyError(FString::Printf(TEXT("Failed to create new Widget Blueprint at '%s'."), *FinalAssetPath)); } - - bIsNewlyCreated = true; // Mark as newly created - - // Mark package as dirty and notify asset registry + + bIsNewlyCreated = true; + Package->MarkPackageDirty(); FAssetRegistryModule::AssetCreated(WidgetBlueprint); - + UE_LOG(LogUmgMcp, Log, TEXT("ApplyJsonToUmgAsset_GameThread: New Widget Blueprint created at '%s'."), *FinalAssetPath); } else { UE_LOG(LogUmgMcp, Error, TEXT("ApplyJsonToUmgAsset_GameThread: Invalid asset path format '%s'."), *FinalAssetPath); - return false; + return MakeApplyError(FString::Printf(TEXT("Invalid asset path format '%s'."), *FinalAssetPath)); } } else @@ -431,11 +692,16 @@ bool ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& Jso UE_LOG(LogUmgMcp, Log, TEXT("ApplyJsonToUmgAsset_GameThread: Widget Blueprint loaded.")); } + if (!WidgetBlueprint) + { + return MakeApplyError(TEXT("Widget Blueprint is null after load/create.")); + } + // 3. Ensure the WidgetTree is valid. if (!WidgetBlueprint->WidgetTree) { UE_LOG(LogUmgMcp, Error, TEXT("ApplyJsonToUmgAsset_GameThread: WidgetTree is null in UWidgetBlueprint '%s'."), *FinalAssetPath); - return false; + return MakeApplyError(FString::Printf(TEXT("WidgetTree is null in '%s'."), *FinalAssetPath)); } UE_LOG(LogUmgMcp, Log, TEXT("ApplyJsonToUmgAsset_GameThread: WidgetTree is valid.")); @@ -492,7 +758,9 @@ bool ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& Jso { UE_LOG(LogUmgMcp, Error, TEXT("ApplyJsonToUmgAsset_GameThread: Class mismatch for widget '%s' (JSON: %s, Existing: %s)."), *WidgetName, *JsonInfo.ClassPath, *ExistingClassPath); - return false; + return MakeApplyError(FString::Printf( + TEXT("Class mismatch for widget '%s' (JSON: %s, Existing: %s)."), + *WidgetName, *JsonInfo.ClassPath, *ExistingClassPath)); } // Parent matching check @@ -516,9 +784,9 @@ bool ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& Jso if (!bParentMatches) { - UE_LOG(LogUmgMcp, Error, TEXT("ApplyJsonToUmgAsset_GameThread: Parent mismatch for widget '%s' (JSON parent: %s, UE tree parent: %s)."), + UE_LOG(LogUmgMcp, Log, TEXT("ApplyJsonToUmgAsset_GameThread: Deferring reparent for '%s' (JSON parent: %s, UE parent: %s)."), *WidgetName, *ExpectedParentName, *ExistingParentName); - return false; + PendingReparents.Add({WidgetName, ExpectedParentName}); } } } @@ -533,7 +801,7 @@ bool ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& Jso if (!NewRootWidget) { UE_LOG(LogUmgMcp, Error, TEXT("ApplyJsonToUmgAsset_GameThread: Failed to create root widget.")); - return false; + return MakeApplyError(TEXT("Failed to create root widget.")); } WidgetBlueprint->WidgetTree->RootWidget = NewRootWidget; } @@ -544,7 +812,7 @@ bool ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& Jso if (!NewSubtree) { UE_LOG(LogUmgMcp, Error, TEXT("ApplyJsonToUmgAsset_GameThread: Failed to create subtree under TargetWidget '%s'."), *TargetWidget->GetName()); - return false; + return MakeApplyError(FString::Printf(TEXT("Failed to create subtree under '%s'."), *TargetWidget->GetName())); } } } @@ -559,20 +827,29 @@ bool ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& Jso { // Fallback for empty tree (should not happen since we have overlap) UWidget* NewRootWidget = CreateWidgetFromJson(RootJsonObject, WidgetBlueprint->WidgetTree, nullptr); - if (!NewRootWidget) return false; + if (!NewRootWidget) return MakeApplyError(TEXT("Failed to create root widget (overlap fallback).")); WidgetBlueprint->WidgetTree->RootWidget = NewRootWidget; } } + for (const FDeferredReparent& Item : PendingReparents) + { + FString ReparentError; + if (!ExecuteReparent(WidgetBlueprint, Item.WidgetName, Item.ExpectedParentName, ReparentError)) + { + return MakeApplyError(ReparentError); + } + ReparentedWidgets.Add(Item.WidgetName); + } + // 7.5. Verify widget tree integrity before marking as modified UE_LOG(LogUmgMcp, Log, TEXT("ApplyJsonToUmgAsset_GameThread: Verifying widget tree integrity.")); if (!WidgetBlueprint->WidgetTree->RootWidget) { UE_LOG(LogUmgMcp, Error, TEXT("ApplyJsonToUmgAsset_GameThread: Root widget became null after assignment!")); - return false; + return MakeApplyError(TEXT("Root widget became null after assignment.")); } - - // Ensure all widgets are properly owned by the WidgetTree + TArray AllWidgets; WidgetBlueprint->WidgetTree->GetAllWidgets(AllWidgets); UE_LOG(LogUmgMcp, Log, TEXT("ApplyJsonToUmgAsset_GameThread: Widget tree contains %d widgets."), AllWidgets.Num()); @@ -600,13 +877,15 @@ bool ApplyJsonToUmgAsset_GameThread(const FString& AssetPath, const FString& Jso } } - // 9. Notify the editor that the blueprint has changed structurally - // This forces the UI to refresh and show the new widgets - FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(WidgetBlueprint); + // 9. Refresh GeneratedClass so preview/lint see the new tree. + // We intentionally avoid MarkBlueprintAsStructurallyModified here: the original author + // documented it as crash-prone in this code path. A plain CompileBlueprint on the + // GameThread is sufficient to regenerate the class without that risk. + FUmgPreviewRenderUtils::CompileWidgetBlueprint(WidgetBlueprint); UE_LOG(LogUmgMcp, Log, TEXT("Successfully applied JSON to UMG asset '%s'."), *FinalAssetPath); - return true; + return MakeApplySuccess(MoveTemp(ReparentedWidgets)); } static void ApplyPropertiesToExistingWidget(const TSharedPtr& WidgetJson, UWidget* TargetWidget) @@ -618,24 +897,17 @@ static void ApplyPropertiesToExistingWidget(const TSharedPtr& Widge const TSharedPtr* PropertiesJsonObjPtr; TSharedPtr WidgetProps = MakeShared(); - TSharedPtr SlotProps = nullptr; + TSharedPtr SlotProps = ExtractSlotPropsFromWidgetJson(WidgetJson); if (WidgetJson->TryGetObjectField(TEXT("properties"), PropertiesJsonObjPtr)) { TSharedPtr SourceProps = *PropertiesJsonObjPtr; for (auto& Pair : SourceProps->Values) { - if (Pair.Key == TEXT("Slot")) - { - const TSharedPtr* SlotObjPtr; - if (Pair.Value->TryGetObject(SlotObjPtr)) - { - SlotProps = *SlotObjPtr; - } - } - else + const FString Key(Pair.Key.ToView()); + if (Key != TEXT("Slot")) { - WidgetProps->SetField(Pair.Key, Pair.Value); + WidgetProps->SetField(Key, Pair.Value); } } } @@ -649,14 +921,9 @@ static void ApplyPropertiesToExistingWidget(const TSharedPtr& Widge } } - if (SlotProps.IsValid() && TargetWidget->Slot) + if (SlotProps.IsValid()) { - TargetWidget->Slot->Modify(); - TSharedPtr NormalizedSlotProps = UUmgFileTransformation::NormalizeJsonKeysToPascalCase(SlotProps); - if (!FJsonObjectConverter::JsonObjectToUStruct(NormalizedSlotProps.ToSharedRef(), TargetWidget->Slot->GetClass(), TargetWidget->Slot, 0, 0)) - { - UE_LOG(LogUmgMcp, Warning, TEXT("ApplyPropertiesToExistingWidget: Failed to apply Slot properties to '%s'."), *TargetWidget->GetName()); - } + ApplySlotPropertiesToWidget(TargetWidget, SlotProps); } } @@ -709,8 +976,9 @@ static void MergeChildrenAppendOnly(const TSharedPtr& WidgetJson, U } else { - UWidget* NewChild = CreateWidgetFromJson(*ChildObject, WidgetTree, TargetWidget); - if (NewChild) + UPanelWidget* EffectiveParent = EnsureSingleChildCapacity(ParentPanel, WidgetTree); + UWidget* NewChild = CreateWidgetFromJson(*ChildObject, WidgetTree, EffectiveParent); + if (NewChild && EffectiveParent == ParentPanel) { const int32 CurrentIndex = ParentPanel->GetChildIndex(NewChild); if (CurrentIndex != InsertIndex && CurrentIndex != INDEX_NONE) @@ -802,25 +1070,17 @@ static UWidget* CreateWidgetFromJson(const TSharedPtr& WidgetJson, // 2. Prepare Property JSONs const TSharedPtr* PropertiesJsonObjPtr; TSharedPtr WidgetProps = MakeShared(); - TSharedPtr SlotProps = nullptr; + TSharedPtr SlotProps = ExtractSlotPropsFromWidgetJson(WidgetJson); if (WidgetJson->TryGetObjectField(TEXT("properties"), PropertiesJsonObjPtr)) { - // Copy properties to a new object so we can remove 'Slot' without modifying source TSharedPtr SourceProps = *PropertiesJsonObjPtr; for (auto& Pair : SourceProps->Values) { - if (Pair.Key == TEXT("Slot")) - { - const TSharedPtr* SlotObjPtr; - if (Pair.Value->TryGetObject(SlotObjPtr)) - { - SlotProps = *SlotObjPtr; - } - } - else + const FString Key(Pair.Key.ToView()); + if (Key != TEXT("Slot")) { - WidgetProps->SetField(Pair.Key, Pair.Value); + WidgetProps->SetField(Key, Pair.Value); } } } @@ -834,45 +1094,13 @@ static UWidget* CreateWidgetFromJson(const TSharedPtr& WidgetJson, } } - // 4. Apply Slot Properties + // 4. Apply Slot Properties (properties.Slot or top-level SlotData) if (SlotProps.IsValid()) { - // Normalize JSON keys from camelCase to PascalCase to match C++ UPROPERTY names UE_LOG(LogUmgMcp, Log, TEXT("CreateWidgetFromJson: Processing Slot properties for widget '%s'"), *WidgetName); - TSharedPtr NormalizedSlotProps = UUmgFileTransformation::NormalizeJsonKeysToPascalCase(SlotProps); - - // Log the normalized Slot JSON for debugging - FString SlotPropsString; - TSharedRef> Writer = TJsonWriterFactory<>::Create(&SlotPropsString); - FJsonSerializer::Serialize(NormalizedSlotProps.ToSharedRef(), Writer); - UE_LOG(LogUmgMcp, Log, TEXT("CreateWidgetFromJson: Normalized Slot JSON for '%s': %s"), *WidgetName, *SlotPropsString); - if (NewSlot) { - UE_LOG(LogUmgMcp, Log, TEXT("CreateWidgetFromJson: Applying Slot properties to NewSlot (class: %s)"), *NewSlot->GetClass()->GetName()); - - if (!FJsonObjectConverter::JsonObjectToUStruct(NormalizedSlotProps.ToSharedRef(), NewSlot->GetClass(), NewSlot, 0, 0)) - { - UE_LOG(LogUmgMcp, Warning, TEXT("CreateWidgetFromJson: Issues applying Slot properties to '%s'."), *WidgetName); - } - else - { - UE_LOG(LogUmgMcp, Log, TEXT("CreateWidgetFromJson: Successfully applied Slot properties to '%s'"), *WidgetName); - } - } - else if (NewWidget->Slot) - { - // Fallback to NewWidget->Slot if AddChild didn't return one but it exists (e.g. RootWidget might not have slot, but this branch is for children) - UE_LOG(LogUmgMcp, Log, TEXT("CreateWidgetFromJson: Applying Slot properties to NewWidget->Slot (class: %s)"), *NewWidget->Slot->GetClass()->GetName()); - - if (!FJsonObjectConverter::JsonObjectToUStruct(NormalizedSlotProps.ToSharedRef(), NewWidget->Slot->GetClass(), NewWidget->Slot, 0, 0)) - { - UE_LOG(LogUmgMcp, Warning, TEXT("CreateWidgetFromJson: Issues applying Slot properties to '%s' (fallback)."), *WidgetName); - } - else - { - UE_LOG(LogUmgMcp, Log, TEXT("CreateWidgetFromJson: Successfully applied Slot properties to '%s' (fallback)"), *WidgetName); - } + ApplySlotPropertiesToWidget(NewWidget, SlotProps); } else { diff --git a/Source/UmgMcp/Private/FileManage/UmgMcpFileTransformationCommands.cpp b/Source/UmgMcp/Private/FileManage/UmgMcpFileTransformationCommands.cpp index 75e5b24..d75fd8d 100644 --- a/Source/UmgMcp/Private/FileManage/UmgMcpFileTransformationCommands.cpp +++ b/Source/UmgMcp/Private/FileManage/UmgMcpFileTransformationCommands.cpp @@ -51,7 +51,8 @@ TSharedPtr FUmgMcpFileTransformationCommands::HandleCommand(const F Params->TryGetStringField(TEXT("target_widget_name"), TargetWidgetName); } - bool bSuccess = UUmgFileTransformation::ApplyJsonStringToUmgAsset(AssetPath, JsonData, TargetWidgetName); + const FApplyJsonToUmgResult ApplyResult = UUmgFileTransformation::ApplyJsonStringToUmgAsset(AssetPath, JsonData, TargetWidgetName); + const bool bSuccess = ApplyResult.bSuccess; if (bSuccess) { ResultJson->SetStringField(TEXT("asset_path"), AssetPath); @@ -60,11 +61,24 @@ TSharedPtr FUmgMcpFileTransformationCommands::HandleCommand(const F ResultJson->SetStringField(TEXT("target_widget"), TargetWidgetName); } ResultJson->SetBoolField(TEXT("success"), true); + ResultJson->SetBoolField(TEXT("applied"), ApplyResult.bApplied); + if (ApplyResult.ReparentedWidgets.Num() > 0) + { + TArray> ReparentedArray; + for (const FString& Name : ApplyResult.ReparentedWidgets) + { + ReparentedArray.Add(MakeShared(Name)); + } + ResultJson->SetArrayField(TEXT("reparented_widgets"), ReparentedArray); + } } else { - ResultJson->SetStringField(TEXT("error"), TEXT("Failed to apply JSON data to UMG asset.")); + ResultJson->SetStringField(TEXT("error"), ApplyResult.ErrorMessage.IsEmpty() + ? TEXT("Failed to apply JSON data to UMG asset.") + : ApplyResult.ErrorMessage); ResultJson->SetBoolField(TEXT("success"), false); + ResultJson->SetBoolField(TEXT("applied"), false); } } else diff --git a/Source/UmgMcp/Private/Lint/UmgLintSubsystem.cpp b/Source/UmgMcp/Private/Lint/UmgLintSubsystem.cpp new file mode 100644 index 0000000..3ff8b30 --- /dev/null +++ b/Source/UmgMcp/Private/Lint/UmgLintSubsystem.cpp @@ -0,0 +1,777 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#include "Lint/UmgLintSubsystem.h" + +#include "Algo/Reverse.h" +#include "WidgetBlueprint.h" +#include "Blueprint/UserWidget.h" +#include "Blueprint/WidgetTree.h" +#include "Components/CanvasPanelSlot.h" +#include "Components/HorizontalBoxSlot.h" +#include "Components/OverlaySlot.h" +#include "Components/PanelWidget.h" +#include "Components/VerticalBoxSlot.h" +#include "Components/Widget.h" +#include "Editor.h" +#include "Engine/TextureRenderTarget2D.h" +#include "Interfaces/IPluginManager.h" +#include "Kismet2/KismetEditorUtilities.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "Preview/UmgPreviewRenderUtils.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" +#include "Slate/WidgetRenderer.h" +#include "Widgets/SVirtualWindow.h" +#include "TextureResource.h" +#include "Widgets/SWidget.h" + +DEFINE_LOG_CATEGORY(LogUmgLint); + +namespace UmgLintInternal +{ + static constexpr int32 MinViewportSize = 16; + static constexpr int32 MaxViewportSize = 4096; + + static int32 ClampViewportSize(int32 Value, int32 DefaultValue) + { + if (Value <= 0) + { + return DefaultValue; + } + return FMath::Clamp(Value, MinViewportSize, MaxViewportSize); + } + + static bool RectsIntersect(const FSlateRect& A, const FSlateRect& B) + { + return FSlateRect::DoRectanglesIntersect(A, B); + } + + static float RectArea(const FSlateRect& R) + { + return FMath::Max(0.f, R.Right - R.Left) * FMath::Max(0.f, R.Bottom - R.Top); + } + + static bool IsDegenerateRect(const FSlateRect& R) + { + return RectArea(R) <= 0.01f; + } +} + +void UUmgLintSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + UE_LOG(LogUmgLint, Log, TEXT("UmgLintSubsystem initialized.")); +} + +void UUmgLintSubsystem::Deinitialize() +{ + UE_LOG(LogUmgLint, Log, TEXT("UmgLintSubsystem deinitialized.")); + Super::Deinitialize(); +} + +FString UUmgLintSubsystem::GetRulesConfigPath() const +{ + if (const TSharedPtr Plugin = IPluginManager::Get().FindPlugin(TEXT("UmgMcp"))) + { + return FPaths::Combine(Plugin->GetBaseDir(), TEXT("Resources/umg_lint_rules.json")); + } + + return FPaths::ConvertRelativePathToFull( + FPaths::Combine(FPaths::ProjectPluginsDir(), TEXT("UnrealMotionGraphicsMCP-main/Resources/umg_lint_rules.json"))); +} + +bool UUmgLintSubsystem::LoadRulesConfig(int32& OutDepthThreshold, TArray>& OutNamingRules) const +{ + OutDepthThreshold = 10; + OutNamingRules.Reset(); + + const FString ConfigPath = GetRulesConfigPath(); + if (!FPaths::FileExists(ConfigPath)) + { + UE_LOG(LogUmgLint, Warning, TEXT("Lint rules file not found: %s"), *ConfigPath); + return false; + } + + FString FileContent; + if (!FFileHelper::LoadFileToString(FileContent, *ConfigPath)) + { + UE_LOG(LogUmgLint, Warning, TEXT("Failed to read lint rules file: %s"), *ConfigPath); + return false; + } + + TSharedPtr ConfigJson; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(FileContent); + if (!FJsonSerializer::Deserialize(Reader, ConfigJson) || !ConfigJson.IsValid()) + { + UE_LOG(LogUmgLint, Warning, TEXT("Failed to parse lint rules JSON: %s"), *ConfigPath); + return false; + } + + double DepthValue = OutDepthThreshold; + if (ConfigJson->TryGetNumberField(TEXT("depth_threshold"), DepthValue)) + { + OutDepthThreshold = FMath::Max(1, static_cast(DepthValue)); + } + + const TArray>* ConventionsArray = nullptr; + if (ConfigJson->TryGetArrayField(TEXT("naming_conventions"), ConventionsArray)) + { + for (const TSharedPtr& Entry : *ConventionsArray) + { + const TSharedPtr* EntryObject = nullptr; + if (!Entry->TryGetObject(EntryObject) || !EntryObject || !EntryObject->IsValid()) + { + continue; + } + + FString ClassSuffix; + FString Prefix; + (*EntryObject)->TryGetStringField(TEXT("class_suffix"), ClassSuffix); + (*EntryObject)->TryGetStringField(TEXT("prefix"), Prefix); + if (!ClassSuffix.IsEmpty() && !Prefix.IsEmpty()) + { + OutNamingRules.Add(TPair(ClassSuffix, Prefix)); + } + } + } + + return true; +} + +bool UUmgLintSubsystem::ShouldRunRule(const FUmgLintOptions& Options, const FString& RuleId) const +{ + if (Options.Rules.Num() == 0) + { + return true; + } + + for (const FString& Rule : Options.Rules) + { + if (Rule.Equals(RuleId, ESearchCase::IgnoreCase)) + { + return true; + } + } + return false; +} + +UUserWidget* UUmgLintSubsystem::CreatePreviewInstance(UWidgetBlueprint* WidgetBlueprint, FString& OutError) const +{ + if (!GEditor) + { + OutError = TEXT("GEditor is unavailable."); + return nullptr; + } + + UWorld* World = GEditor->GetEditorWorldContext().World(); + return FUmgPreviewRenderUtils::CreatePreviewInstance(WidgetBlueprint, World, OutError); +} + +void UUmgLintSubsystem::DestroyPreviewInstance(UUserWidget* PreviewWidget) const +{ + if (PreviewWidget) + { + PreviewWidget->ConditionalBeginDestroy(); + } +} + +bool UUmgLintSubsystem::ArrangePreviewGeometry(UUserWidget* PreviewWidget, int32 ViewportWidth, int32 ViewportHeight) const +{ + if (!PreviewWidget) + { + return false; + } + + TSharedPtr RootSlate = PreviewWidget->GetCachedWidget(); + if (!RootSlate.IsValid()) + { + return false; + } + + const FVector2D DrawSize( + static_cast(UmgLintInternal::ClampViewportSize(ViewportWidth, 1920)), + static_cast(UmgLintInternal::ClampViewportSize(ViewportHeight, 1080))); + + TSharedPtr VirtualHost; + const TSharedRef RenderTree = FUmgPreviewRenderUtils::PrepareOffscreenRenderTree( + RootSlate.ToSharedRef(), + DrawSize, + VirtualHost); + + UTextureRenderTarget2D* RenderTarget = NewObject(GetTransientPackage()); + if (!RenderTarget) + { + return false; + } + + RenderTarget->RenderTargetFormat = RTF_RGBA8; + RenderTarget->ClearColor = FLinearColor(0.f, 0.f, 0.f, 0.f); + RenderTarget->InitAutoFormat( + UmgLintInternal::ClampViewportSize(ViewportWidth, 1920), + UmgLintInternal::ClampViewportSize(ViewportHeight, 1080)); + RenderTarget->UpdateResourceImmediate(true); + + FWidgetRenderer WidgetRenderer(true); + WidgetRenderer.DrawWidget(RenderTarget, RenderTree, DrawSize, 0.f, false); + FlushRenderingCommands(); + + FUmgPreviewRenderUtils::ReleaseOffscreenRenderHost(VirtualHost); + return true; +} + +bool UUmgLintSubsystem::IsRectValidForOverlap(const FSlateRect& Rect) +{ + return !UmgLintInternal::IsDegenerateRect(Rect); +} + +TSharedPtr UUmgLintSubsystem::BuildOverlapFixSuggestion( + UWidget* MoveWidget, + UPanelWidget* ParentPanel, + float OverlapHeight, + float OverlapWidth) +{ + if (!MoveWidget || !ParentPanel || !MoveWidget->Slot) + { + return nullptr; + } + + const float PadValue = FMath::CeilToFloat(FMath::Max(OverlapHeight, OverlapWidth) + 1.f); + TSharedPtr Fix = MakeShared(); + TSharedPtr Params = MakeShared(); + TSharedPtr Properties = MakeShared(); + TSharedPtr Slot = MakeShared(); + TSharedPtr Meta = MakeShared(); + + Params->SetStringField(TEXT("widget_name"), MoveWidget->GetName()); + Meta->SetBoolField(TEXT("autoApplicable"), true); + Meta->SetStringField(TEXT("slotClass"), MoveWidget->Slot->GetClass()->GetName()); + Meta->SetStringField(TEXT("parentClass"), ParentPanel->GetClass()->GetName()); + + if (MoveWidget->Slot->IsA(UCanvasPanelSlot::StaticClass())) + { + TSharedPtr LayoutData = MakeShared(); + TSharedPtr Offsets = MakeShared(); + Offsets->SetNumberField(TEXT("Top"), FMath::CeilToFloat(OverlapHeight + 1.f)); + Offsets->SetNumberField(TEXT("Left"), FMath::CeilToFloat(OverlapWidth + 1.f)); + LayoutData->SetObjectField(TEXT("Offsets"), Offsets); + Slot->SetObjectField(TEXT("LayoutData"), LayoutData); + } + else if (MoveWidget->Slot->IsA(UVerticalBoxSlot::StaticClass())) + { + TSharedPtr Padding = MakeShared(); + Padding->SetNumberField(TEXT("Bottom"), PadValue); + Slot->SetObjectField(TEXT("Padding"), Padding); + } + else if (MoveWidget->Slot->IsA(UHorizontalBoxSlot::StaticClass())) + { + TSharedPtr Padding = MakeShared(); + Padding->SetNumberField(TEXT("Right"), PadValue); + Slot->SetObjectField(TEXT("Padding"), Padding); + } + else if (MoveWidget->Slot->IsA(UOverlaySlot::StaticClass())) + { + TSharedPtr Padding = MakeShared(); + Padding->SetNumberField(TEXT("Top"), PadValue); + Slot->SetObjectField(TEXT("Padding"), Padding); + } + else + { + return nullptr; + } + + Properties->SetObjectField(TEXT("Slot"), Slot); + Params->SetObjectField(TEXT("properties"), Properties); + Fix->SetStringField(TEXT("action"), TEXT("set_widget_properties")); + Fix->SetObjectField(TEXT("params"), Params); + Fix->SetObjectField(TEXT("meta"), Meta); + return Fix; +} + +FString UUmgLintSubsystem::BuildWidgetPath(UWidget* Widget) +{ + TArray Parts; + while (Widget) + { + Parts.Add(Widget->GetName()); + Widget = Widget->GetParent(); + } + Algo::Reverse(Parts); + return FString::Join(Parts, TEXT("/")); +} + +int32 UUmgLintSubsystem::GetWidgetDepth(UWidget* Widget) +{ + int32 Depth = 0; + while (Widget) + { + ++Depth; + Widget = Widget->GetParent(); + } + return Depth; +} + +bool UUmgLintSubsystem::IsWidgetVisibleForLint(UWidget* Widget) +{ + if (!Widget) + { + return false; + } + + const ESlateVisibility Visibility = Widget->GetVisibility(); + return Visibility != ESlateVisibility::Collapsed && Visibility != ESlateVisibility::Hidden; +} + +bool UUmgLintSubsystem::IsWidgetHitTestBlocking(UWidget* Widget) +{ + if (!Widget || !IsWidgetVisibleForLint(Widget)) + { + return false; + } + + const ESlateVisibility Visibility = Widget->GetVisibility(); + return Visibility == ESlateVisibility::Visible; +} + +bool UUmgLintSubsystem::ShouldSkipOverlapCheck(UWidget* Widget) +{ + return !Widget || !IsWidgetVisibleForLint(Widget); +} + +void UUmgLintSubsystem::RunNamingConventionRule( + UWidgetBlueprint* WidgetBlueprint, + const TArray>& NamingRules, + TArray& OutIssues) +{ + if (!WidgetBlueprint || !WidgetBlueprint->WidgetTree || NamingRules.Num() == 0) + { + return; + } + + TArray AllWidgets; + WidgetBlueprint->WidgetTree->GetAllWidgets(AllWidgets); + + for (UWidget* Widget : AllWidgets) + { + if (!Widget) + { + continue; + } + + const FString ClassName = Widget->GetClass()->GetName(); + const FString WidgetName = Widget->GetName(); + + for (const TPair& Rule : NamingRules) + { + if (!ClassName.Contains(Rule.Key)) + { + continue; + } + + if (WidgetName.StartsWith(Rule.Value)) + { + break; + } + + FUmgLintIssue Issue; + Issue.RuleId = TEXT("naming-convention"); + Issue.Severity = TEXT("warning"); + Issue.Message = FString::Printf( + TEXT("Widget '%s' (%s) should use prefix '%s' per naming convention."), + *WidgetName, *ClassName, *Rule.Value); + Issue.WidgetPath = BuildWidgetPath(Widget); + Issue.Source = TEXT("asset-time"); + OutIssues.Add(Issue); + break; + } + } +} + +void UUmgLintSubsystem::RunNestingDepthRule( + UWidgetBlueprint* WidgetBlueprint, + int32 DepthThreshold, + TArray& OutIssues) +{ + if (!WidgetBlueprint || !WidgetBlueprint->WidgetTree) + { + return; + } + + TArray AllWidgets; + WidgetBlueprint->WidgetTree->GetAllWidgets(AllWidgets); + + for (UWidget* Widget : AllWidgets) + { + if (!Widget) + { + continue; + } + + const int32 Depth = GetWidgetDepth(Widget); + if (Depth <= DepthThreshold) + { + continue; + } + + FUmgLintIssue Issue; + Issue.RuleId = TEXT("nesting-depth-limit"); + Issue.Severity = TEXT("warning"); + Issue.Message = FString::Printf( + TEXT("Widget hierarchy depth is %d (limit %d). Consider splitting into a nested UserWidget."), + Depth, DepthThreshold); + Issue.WidgetPath = BuildWidgetPath(Widget); + Issue.Source = TEXT("asset-time"); + OutIssues.Add(Issue); + } +} + +void UUmgLintSubsystem::RunLayoutOverlapRule( + UUserWidget* PreviewWidget, + int32 ViewportWidth, + int32 ViewportHeight, + TArray& OutIssues, + bool& bPreviewReady) +{ + bPreviewReady = false; + if (!PreviewWidget || !PreviewWidget->WidgetTree) + { + return; + } + + if (!ArrangePreviewGeometry(PreviewWidget, ViewportWidth, ViewportHeight)) + { + return; + } + + TArray AllWidgets; + PreviewWidget->WidgetTree->GetAllWidgets(AllWidgets); + + TMap> ChildrenByParent; + int32 ValidGeometryCount = 0; + for (UWidget* Widget : AllWidgets) + { + if (!Widget || ShouldSkipOverlapCheck(Widget)) + { + continue; + } + + if (!Widget->GetCachedWidget().IsValid()) + { + continue; + } + + const FSlateRect Rect = Widget->GetCachedWidget()->GetTickSpaceGeometry().GetLayoutBoundingRect(); + if (IsRectValidForOverlap(Rect)) + { + ++ValidGeometryCount; + } + + UPanelWidget* ParentPanel = Cast(Widget->GetParent()); + if (!ParentPanel) + { + continue; + } + + ChildrenByParent.FindOrAdd(ParentPanel).Add(Widget); + } + + if (ChildrenByParent.Num() == 0 || ValidGeometryCount == 0) + { + return; + } + + bPreviewReady = true; + + for (const TPair>& Pair : ChildrenByParent) + { + UPanelWidget* ParentPanel = Pair.Key; + const TArray& Siblings = Pair.Value; + for (int32 I = 0; I < Siblings.Num(); ++I) + { + UWidget* WidgetA = Siblings[I]; + if (!WidgetA || !WidgetA->GetCachedWidget().IsValid()) + { + continue; + } + + const FSlateRect RectA = WidgetA->GetCachedWidget()->GetTickSpaceGeometry().GetLayoutBoundingRect(); + if (!IsRectValidForOverlap(RectA)) + { + continue; + } + + for (int32 J = I + 1; J < Siblings.Num(); ++J) + { + UWidget* WidgetB = Siblings[J]; + if (!WidgetB || !WidgetB->GetCachedWidget().IsValid()) + { + continue; + } + + if (ShouldSkipOverlapCheck(WidgetA) || ShouldSkipOverlapCheck(WidgetB)) + { + continue; + } + + const FSlateRect RectB = WidgetB->GetCachedWidget()->GetTickSpaceGeometry().GetLayoutBoundingRect(); + if (!IsRectValidForOverlap(RectB) || !UmgLintInternal::RectsIntersect(RectA, RectB)) + { + continue; + } + + const bool bAHitTest = IsWidgetHitTestBlocking(WidgetA); + const bool bBHitTest = IsWidgetHitTestBlocking(WidgetB); + const bool bBothHitTest = bAHitTest && bBHitTest; + + FUmgLintIssue Issue; + Issue.RuleId = TEXT("layout-overlap"); + Issue.Severity = bBothHitTest ? TEXT("error") : TEXT("warning"); + Issue.WidgetPath = BuildWidgetPath(WidgetA); + Issue.RelatedWidgets.Add(WidgetB->GetName()); + Issue.Source = TEXT("asset-time"); + + if (bBothHitTest) + { + Issue.Message = FString::Printf( + TEXT("'%s' overlaps '%s' under %s and both are hit-test visible; clicks may be intercepted."), + *WidgetA->GetName(), *WidgetB->GetName(), *ParentPanel->GetClass()->GetName()); + } + else + { + Issue.Message = FString::Printf( + TEXT("'%s' visually overlaps '%s' under %s."), + *WidgetA->GetName(), *WidgetB->GetName(), *ParentPanel->GetClass()->GetName()); + } + + const bool bMoveA = UmgLintInternal::RectArea(RectA) <= UmgLintInternal::RectArea(RectB); + UWidget* MoveWidget = bMoveA ? WidgetA : WidgetB; + const FSlateRect OverlapRect( + FMath::Max(RectA.Left, RectB.Left), + FMath::Max(RectA.Top, RectB.Top), + FMath::Min(RectA.Right, RectB.Right), + FMath::Min(RectA.Bottom, RectB.Bottom)); + const float OverlapHeight = FMath::Max(0.f, OverlapRect.Bottom - OverlapRect.Top); + const float OverlapWidth = FMath::Max(0.f, OverlapRect.Right - OverlapRect.Left); + + if (TSharedPtr Fix = BuildOverlapFixSuggestion(MoveWidget, ParentPanel, OverlapHeight, OverlapWidth)) + { + Issue.FixSuggestion = Fix; + } + else + { + Issue.Message += TEXT(" Manual fix required: adjust Slot properties compatible with the parent panel type."); + } + + OutIssues.Add(Issue); + } + } + } +} + +TSharedPtr UUmgLintSubsystem::IssueToJson(const FUmgLintIssue& Issue) +{ + TSharedPtr Json = MakeShared(); + Json->SetStringField(TEXT("ruleId"), Issue.RuleId); + Json->SetStringField(TEXT("severity"), Issue.Severity); + Json->SetStringField(TEXT("message"), Issue.Message); + Json->SetStringField(TEXT("widgetPath"), Issue.WidgetPath); + Json->SetStringField(TEXT("source"), Issue.Source); + + if (Issue.RelatedWidgets.Num() > 0) + { + TArray> Related; + for (const FString& Name : Issue.RelatedWidgets) + { + Related.Add(MakeShared(Name)); + } + Json->SetArrayField(TEXT("relatedWidgets"), Related); + } + + if (Issue.FixSuggestion.IsValid()) + { + Json->SetObjectField(TEXT("fixSuggestion"), Issue.FixSuggestion); + } + + return Json; +} + +TSharedPtr UUmgLintSubsystem::BuildReport(UWidgetBlueprint* WidgetBlueprint, const TArray& Issues, bool bGeometryTrusted) +{ + int32 ErrorCount = 0; + int32 WarningCount = 0; + int32 InfoCount = 0; + + TArray> IssuesArray; + for (const FUmgLintIssue& Issue : Issues) + { + if (Issue.Severity == TEXT("error")) + { + ++ErrorCount; + } + else if (Issue.Severity == TEXT("warning")) + { + ++WarningCount; + } + else + { + ++InfoCount; + } + IssuesArray.Add(MakeShared(IssueToJson(Issue))); + } + + TSharedPtr Summary = MakeShared(); + Summary->SetNumberField(TEXT("error"), ErrorCount); + Summary->SetNumberField(TEXT("warning"), WarningCount); + Summary->SetNumberField(TEXT("info"), InfoCount); + + TSharedPtr Report = MakeShared(); + Report->SetBoolField(TEXT("success"), true); + if (WidgetBlueprint) + { + Report->SetStringField(TEXT("assetPath"), WidgetBlueprint->GetPathName()); + } + Report->SetBoolField(TEXT("geometryTrusted"), bGeometryTrusted); + Report->SetBoolField(TEXT("ok"), ErrorCount == 0 && bGeometryTrusted); + Report->SetObjectField(TEXT("summary"), Summary); + Report->SetArrayField(TEXT("issues"), IssuesArray); + return Report; +} + +FString UUmgLintSubsystem::SerializeJsonObject(const TSharedPtr& JsonObject) +{ + FString Output; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Output); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + return Output; +} + +FString UUmgLintSubsystem::AnalyzeAsset(UWidgetBlueprint* WidgetBlueprint, const FUmgLintOptions& Options) +{ + TArray Issues; + + if (!WidgetBlueprint) + { + TSharedPtr ErrorReport = MakeShared(); + ErrorReport->SetBoolField(TEXT("success"), false); + ErrorReport->SetStringField(TEXT("error"), TEXT("WidgetBlueprint is null.")); + return SerializeJsonObject(ErrorReport); + } + + int32 DepthThreshold = Options.DepthThreshold > 0 ? Options.DepthThreshold : 10; + TArray> NamingRules; + int32 ConfigDepth = DepthThreshold; + if (LoadRulesConfig(ConfigDepth, NamingRules)) + { + if (Options.DepthThreshold <= 0) + { + DepthThreshold = ConfigDepth; + } + } + + if (ShouldRunRule(Options, TEXT("naming-convention"))) + { + RunNamingConventionRule(WidgetBlueprint, NamingRules, Issues); + } + + if (ShouldRunRule(Options, TEXT("nesting-depth-limit"))) + { + RunNestingDepthRule(WidgetBlueprint, DepthThreshold, Issues); + } + + bool bGeometryTrusted = true; + if (ShouldRunRule(Options, TEXT("layout-overlap"))) + { + FString PreviewError; + UUserWidget* PreviewWidget = CreatePreviewInstance(WidgetBlueprint, PreviewError); + bool bPreviewReady = false; + + if (PreviewWidget) + { + RunLayoutOverlapRule(PreviewWidget, Options.ViewportWidth, Options.ViewportHeight, Issues, bPreviewReady); + bGeometryTrusted = bPreviewReady; + DestroyPreviewInstance(PreviewWidget); + } + else + { + bGeometryTrusted = false; + } + + if (!bPreviewReady) + { + FUmgLintIssue InfoIssue; + InfoIssue.RuleId = TEXT("preview-not-ready"); + InfoIssue.Severity = TEXT("warning"); + InfoIssue.Message = PreviewError.IsEmpty() + ? TEXT("Layout geometry was not arranged via off-screen render; overlap results are not trusted for preview gating.") + : PreviewError; + InfoIssue.WidgetPath = TEXT(""); + InfoIssue.Source = TEXT("asset-time"); + Issues.Add(InfoIssue); + } + } + + return SerializeJsonObject(BuildReport(WidgetBlueprint, Issues, bGeometryTrusted)); +} + +FString UUmgLintSubsystem::GetLayoutDataFromPreview( + UWidgetBlueprint* WidgetBlueprint, + int32 ViewportWidth, + int32 ViewportHeight) +{ + if (!WidgetBlueprint) + { + TSharedPtr ErrorReport = MakeShared(); + ErrorReport->SetBoolField(TEXT("success"), false); + ErrorReport->SetStringField(TEXT("error"), TEXT("WidgetBlueprint is null.")); + return SerializeJsonObject(ErrorReport); + } + + FString PreviewError; + UUserWidget* PreviewWidget = CreatePreviewInstance(WidgetBlueprint, PreviewError); + if (!PreviewWidget) + { + TSharedPtr ErrorReport = MakeShared(); + ErrorReport->SetBoolField(TEXT("success"), false); + ErrorReport->SetStringField(TEXT("error"), PreviewError); + return SerializeJsonObject(ErrorReport); + } + + const bool bGeometryTrusted = ArrangePreviewGeometry(PreviewWidget, ViewportWidth, ViewportHeight); + + TArray AllWidgets; + PreviewWidget->WidgetTree->GetAllWidgets(AllWidgets); + + TArray> LayoutDataArray; + for (UWidget* Widget : AllWidgets) + { + if (!Widget || !Widget->GetCachedWidget().IsValid()) + { + continue; + } + + const FSlateRect BoundingBox = Widget->GetCachedWidget()->GetTickSpaceGeometry().GetLayoutBoundingRect(); + if (!IsRectValidForOverlap(BoundingBox)) + { + continue; + } + + TSharedPtr Entry = MakeShared(); + Entry->SetStringField(TEXT("widget_name"), Widget->GetName()); + Entry->SetStringField(TEXT("widget_path"), BuildWidgetPath(Widget)); + Entry->SetNumberField(TEXT("left"), BoundingBox.Left); + Entry->SetNumberField(TEXT("top"), BoundingBox.Top); + Entry->SetNumberField(TEXT("right"), BoundingBox.Right); + Entry->SetNumberField(TEXT("bottom"), BoundingBox.Bottom); + LayoutDataArray.Add(MakeShared(Entry)); + } + + DestroyPreviewInstance(PreviewWidget); + + TSharedPtr Result = MakeShared(); + Result->SetBoolField(TEXT("success"), true); + Result->SetBoolField(TEXT("geometryTrusted"), bGeometryTrusted && LayoutDataArray.Num() > 0); + Result->SetArrayField(TEXT("layout_data"), LayoutDataArray); + Result->SetNumberField(TEXT("viewport_width"), UmgLintInternal::ClampViewportSize(ViewportWidth, 1920)); + Result->SetNumberField(TEXT("viewport_height"), UmgLintInternal::ClampViewportSize(ViewportHeight, 1080)); + return SerializeJsonObject(Result); +} diff --git a/Source/UmgMcp/Private/Material/UmgMcpMaterialSubsystem.cpp b/Source/UmgMcp/Private/Material/UmgMcpMaterialSubsystem.cpp index 9be91aa..63c68a6 100644 --- a/Source/UmgMcp/Private/Material/UmgMcpMaterialSubsystem.cpp +++ b/Source/UmgMcp/Private/Material/UmgMcpMaterialSubsystem.cpp @@ -787,7 +787,7 @@ bool UUmgMcpMaterialSubsystem::SetNodeProperties(const FString& NodeHandle, cons // Use Reflection to apply properties for (const auto& Elem : Properties->Values) { - FString PropName = Elem.Key; + FString PropName(Elem.Key.ToView()); TSharedPtr JsonVal = Elem.Value; FProperty* Prop = TargetObject->GetClass()->FindPropertyByName(*PropName); diff --git a/Source/UmgMcp/Private/Orchestration/UmgMcpOrchestrationCommands.cpp b/Source/UmgMcp/Private/Orchestration/UmgMcpOrchestrationCommands.cpp new file mode 100644 index 0000000..cb5e82b --- /dev/null +++ b/Source/UmgMcp/Private/Orchestration/UmgMcpOrchestrationCommands.cpp @@ -0,0 +1,203 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#include "Orchestration/UmgMcpOrchestrationCommands.h" + +#include "Bridge/UmgMcpCommonUtils.h" +#include "Catalog/UmgCatalogSubsystem.h" +#include "Dom/JsonObject.h" +#include "Editor.h" +#include "FileManage/UmgAttentionSubsystem.h" +#include "Patch/UmgPatchSubsystem.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" +#include "Theme/UmgThemeSubsystem.h" +#include "WidgetBlueprint.h" + +namespace UmgOrchestrationInternal +{ + static bool MergeJsonResponse(const FString& JsonString, TSharedPtr& OutResponse) + { + TSharedPtr Parsed; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonString); + if (!FJsonSerializer::Deserialize(Reader, Parsed) || !Parsed.IsValid()) + { + return false; + } + + for (const TPair>& Field : Parsed->Values) + { + OutResponse->SetField(Field.Key, Field.Value); + } + return true; + } +} + +TSharedPtr FUmgMcpOrchestrationCommands::HandleCommand(const FString& CommandType, const TSharedPtr& Params) +{ + if (!GEditor) + { + return FUmgMcpCommonUtils::CreateErrorResponse(TEXT("Editor unavailable.")); + } + + TSharedPtr Response = MakeShared(); + + if (CommandType == TEXT("theme_get")) + { + UUmgThemeSubsystem* Theme = GEditor->GetEditorSubsystem(); + if (!Theme) + { + return FUmgMcpCommonUtils::CreateErrorResponse(TEXT("UmgThemeSubsystem unavailable.")); + } + + FString OptionalPath; + if (Params.IsValid()) + { + Params->TryGetStringField(TEXT("theme_path"), OptionalPath); + } + Theme->ReloadThemeCache(OptionalPath); + + TSharedPtr ThemeObject; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(Theme->GetThemeJsonString()); + FJsonSerializer::Deserialize(Reader, ThemeObject); + + Response->SetBoolField(TEXT("success"), true); + Response->SetStringField(TEXT("theme_path"), Theme->GetActiveThemeAssetPath()); + Response->SetObjectField(TEXT("theme"), ThemeObject.IsValid() ? ThemeObject : MakeShared()); + return Response; + } + + if (CommandType == TEXT("theme_apply")) + { + UUmgThemeSubsystem* Theme = GEditor->GetEditorSubsystem(); + if (!Theme) + { + return FUmgMcpCommonUtils::CreateErrorResponse(TEXT("UmgThemeSubsystem unavailable.")); + } + + const TSharedPtr* PatchObject = nullptr; + if (!Params.IsValid() || !Params->TryGetObjectField(TEXT("patch"), PatchObject) || !PatchObject || !(*PatchObject).IsValid()) + { + return FUmgMcpCommonUtils::CreateErrorResponse(TEXT("Missing 'patch' object.")); + } + + FString OptionalPath; + Params->TryGetStringField(TEXT("theme_path"), OptionalPath); + + const bool bOk = Theme->ApplyThemePatch(*PatchObject, OptionalPath); + Response->SetBoolField(TEXT("success"), bOk); + Response->SetStringField(TEXT("theme_path"), Theme->GetActiveThemeAssetPath()); + if (bOk) + { + TSharedPtr ThemeObject; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(Theme->GetThemeJsonString()); + FJsonSerializer::Deserialize(Reader, ThemeObject); + Response->SetObjectField(TEXT("theme"), ThemeObject.IsValid() ? ThemeObject : MakeShared()); + } + else + { + Response->SetStringField(TEXT("error"), TEXT("Failed to apply theme patch.")); + } + return Response; + } + + if (CommandType == TEXT("theme_resolve_token")) + { + UUmgThemeSubsystem* Theme = GEditor->GetEditorSubsystem(); + if (!Theme) + { + return FUmgMcpCommonUtils::CreateErrorResponse(TEXT("UmgThemeSubsystem unavailable.")); + } + + FString Token; + if (!Params.IsValid() || !Params->TryGetStringField(TEXT("token"), Token)) + { + return FUmgMcpCommonUtils::CreateErrorResponse(TEXT("Missing 'token' parameter.")); + } + + const FString Resolved = Theme->ResolveToken(Token); + Response->SetBoolField(TEXT("success"), !Resolved.IsEmpty()); + Response->SetStringField(TEXT("token"), Token); + Response->SetStringField(TEXT("resolved"), Resolved); + return Response; + } + + if (CommandType == TEXT("catalog_list")) + { + UUmgCatalogSubsystem* Catalog = GEditor->GetEditorSubsystem(); + if (!Catalog) + { + return FUmgMcpCommonUtils::CreateErrorResponse(TEXT("UmgCatalogSubsystem unavailable.")); + } + + FString Root = TEXT("/Game/UI"); + bool bRecursive = true; + if (Params.IsValid()) + { + Params->TryGetStringField(TEXT("root"), Root); + Params->TryGetBoolField(TEXT("recursive"), bRecursive); + } + + Response->SetBoolField(TEXT("success"), true); + UmgOrchestrationInternal::MergeJsonResponse(Catalog->ListComponents(Root, bRecursive), Response); + return Response; + } + + if (CommandType == TEXT("catalog_describe")) + { + UUmgCatalogSubsystem* Catalog = GEditor->GetEditorSubsystem(); + if (!Catalog) + { + return FUmgMcpCommonUtils::CreateErrorResponse(TEXT("UmgCatalogSubsystem unavailable.")); + } + + FString Path; + if (!Params.IsValid() || !Params->TryGetStringField(TEXT("path"), Path)) + { + return FUmgMcpCommonUtils::CreateErrorResponse(TEXT("Missing 'path' parameter.")); + } + + UmgOrchestrationInternal::MergeJsonResponse(Catalog->DescribeComponent(Path), Response); + return Response; + } + + if (CommandType == TEXT("get_patch_revision")) + { + UUmgAttentionSubsystem* Attention = GEditor->GetEditorSubsystem(); + if (!Attention) + { + return FUmgMcpCommonUtils::CreateErrorResponse(TEXT("UmgAttentionSubsystem unavailable.")); + } + + Response->SetBoolField(TEXT("success"), true); + Response->SetNumberField(TEXT("revision"), Attention->GetPatchRevision()); + return Response; + } + + if (CommandType == TEXT("patch_apply")) + { + UUmgPatchSubsystem* PatchSystem = GEditor->GetEditorSubsystem(); + UUmgAttentionSubsystem* Attention = GEditor->GetEditorSubsystem(); + if (!PatchSystem || !Attention) + { + return FUmgMcpCommonUtils::CreateErrorResponse(TEXT("Patch or Attention subsystem unavailable.")); + } + + const TArray>* PatchArray = nullptr; + if (!Params.IsValid() || !Params->TryGetArrayField(TEXT("patch"), PatchArray)) + { + return FUmgMcpCommonUtils::CreateErrorResponse(TEXT("Missing 'patch' array.")); + } + + TOptional ExpectedRevision; + double ExpectedRevisionNumber = 0.0; + if (Params->TryGetNumberField(TEXT("expected_revision"), ExpectedRevisionNumber)) + { + ExpectedRevision = static_cast(ExpectedRevisionNumber); + } + + UWidgetBlueprint* TargetBP = Attention->GetCachedTargetWidgetBlueprint(); + UmgOrchestrationInternal::MergeJsonResponse(PatchSystem->ApplyPatch(TargetBP, *PatchArray, ExpectedRevision), Response); + return Response; + } + + return FUmgMcpCommonUtils::CreateErrorResponse(FString::Printf(TEXT("Unknown orchestration command: %s"), *CommandType)); +} diff --git a/Source/UmgMcp/Private/Patch/UmgPatchSubsystem.cpp b/Source/UmgMcp/Private/Patch/UmgPatchSubsystem.cpp new file mode 100644 index 0000000..947079a --- /dev/null +++ b/Source/UmgMcp/Private/Patch/UmgPatchSubsystem.cpp @@ -0,0 +1,334 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#include "Patch/UmgPatchSubsystem.h" + +#include "Dom/JsonObject.h" +#include "Dom/JsonValue.h" +#include "Editor.h" +#include "FileManage/UmgAttentionSubsystem.h" +#include "ScopedTransaction.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" +#include "Widget/UmgSetSubsystem.h" +#include "WidgetBlueprint.h" + +DEFINE_LOG_CATEGORY(LogUmgPatch); + +void UUmgPatchSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + UE_LOG(LogUmgPatch, Log, TEXT("UmgPatchSubsystem initialized.")); +} + +bool UUmgPatchSubsystem::ParsePatchPath(const FString& Path, const FString& OpType, FPatchPathInfo& OutInfo, FString& OutError) const +{ + OutInfo = FPatchPathInfo(); + OutError.Empty(); + + FString Normalized = Path; + Normalized.TrimStartAndEndInline(); + if (!Normalized.StartsWith(TEXT("/widgets/"))) + { + OutError = FString::Printf(TEXT("Invalid patch path (must start with /widgets/): %s"), *Path); + return false; + } + + Normalized = Normalized.Mid(9); + TArray Segments; + Normalized.ParseIntoArray(Segments, TEXT("/"), true); + if (Segments.Num() < 1 || Segments[0].IsEmpty()) + { + OutError = TEXT("Missing widget name in patch path."); + return false; + } + + OutInfo.WidgetName = Segments[0]; + OutInfo.ParentName = Segments[0]; + + if (OpType == TEXT("remove")) + { + if (Segments.Num() != 1) + { + OutError = TEXT("remove op path must be exactly /widgets/{WidgetName} (elimination only)."); + return false; + } + return true; + } + + if (Segments.Num() == 1) + { + if (OpType == TEXT("set")) + { + OutError = TEXT("set op path must include /properties/{PropertyName}."); + return false; + } + OutError = FString::Printf(TEXT("Unsupported single-segment path for op '%s'."), *OpType); + return false; + } + + if (Segments[1] == TEXT("properties")) + { + if (OpType != TEXT("set")) + { + OutError = FString::Printf(TEXT("properties path is only valid for set op, not '%s'."), *OpType); + return false; + } + if (Segments.Num() < 3) + { + OutError = TEXT("set op path must include a property name after /properties/."); + return false; + } + + OutInfo.PropertyPath = Segments[2]; + for (int32 Index = 3; Index < Segments.Num(); ++Index) + { + OutInfo.PropertyPath += TEXT(".") + Segments[Index]; + } + return true; + } + + if (Segments[1] == TEXT("children")) + { + if (OpType != TEXT("add")) + { + OutError = FString::Printf(TEXT("children path is only valid for add op, not '%s'."), *OpType); + return false; + } + OutInfo.bIsChildAppend = Segments.Num() >= 3 && Segments[2] == TEXT("-"); + if (!OutInfo.bIsChildAppend) + { + OutError = TEXT("add op path must be /widgets/{Parent}/children/-."); + return false; + } + return true; + } + + OutError = FString::Printf(TEXT("Unsupported path segments in: %s"), *Path); + return false; +} + +bool UUmgPatchSubsystem::ApplySetOp(UWidgetBlueprint* WidgetBlueprint, const FPatchPathInfo& PathInfo, const TSharedPtr& Value, FString& OutError) const +{ + if (PathInfo.PropertyPath.IsEmpty() || !Value.IsValid()) + { + OutError = TEXT("Invalid set op: missing property path or value."); + return false; + } + + UUmgSetSubsystem* SetSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr; + if (!SetSubsystem) + { + OutError = TEXT("UmgSetSubsystem unavailable."); + return false; + } + + TSharedPtr Props = MakeShared(); + Props->SetField(PathInfo.PropertyPath, Value); + + FString PropsJson; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&PropsJson); + FJsonSerializer::Serialize(Props.ToSharedRef(), Writer); + + if (!SetSubsystem->SetWidgetProperties(WidgetBlueprint, PathInfo.WidgetName, PropsJson)) + { + OutError = FString::Printf(TEXT("set failed for widget '%s' property '%s'."), *PathInfo.WidgetName, *PathInfo.PropertyPath); + return false; + } + + return true; +} + +bool UUmgPatchSubsystem::ApplyAddOp(UWidgetBlueprint* WidgetBlueprint, const FPatchPathInfo& PathInfo, const TSharedPtr& Value, FString& OutError) const +{ + const TSharedPtr* ValueObject = nullptr; + if (!Value.IsValid() || !Value->TryGetObject(ValueObject) || !ValueObject || !(*ValueObject).IsValid()) + { + OutError = TEXT("add op requires an object value with type/name."); + return false; + } + + FString WidgetType; + FString NewName; + (*ValueObject)->TryGetStringField(TEXT("type"), WidgetType); + if (WidgetType.IsEmpty()) + { + (*ValueObject)->TryGetStringField(TEXT("widget_type"), WidgetType); + } + if (WidgetType.IsEmpty()) + { + (*ValueObject)->TryGetStringField(TEXT("widget_class"), WidgetType); + } + + (*ValueObject)->TryGetStringField(TEXT("name"), NewName); + if (NewName.IsEmpty()) + { + (*ValueObject)->TryGetStringField(TEXT("widget_name"), NewName); + } + + if (WidgetType.IsEmpty() || NewName.IsEmpty()) + { + OutError = TEXT("add op value must include type and name."); + return false; + } + + UUmgSetSubsystem* SetSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr; + if (!SetSubsystem) + { + OutError = TEXT("UmgSetSubsystem unavailable."); + return false; + } + + const FString Created = SetSubsystem->CreateWidget(WidgetBlueprint, PathInfo.WidgetName, WidgetType, NewName); + if (Created.IsEmpty()) + { + OutError = FString::Printf(TEXT("add failed: could not create '%s' under '%s'."), *NewName, *PathInfo.WidgetName); + return false; + } + + return true; +} + +bool UUmgPatchSubsystem::ApplyRemoveOp(UWidgetBlueprint* WidgetBlueprint, const FPatchPathInfo& PathInfo, FString& OutError) const +{ + UUmgSetSubsystem* SetSubsystem = GEditor ? GEditor->GetEditorSubsystem() : nullptr; + if (!SetSubsystem) + { + OutError = TEXT("UmgSetSubsystem unavailable."); + return false; + } + + UE_LOG(LogUmgPatch, Log, TEXT("Patch remove: eliminating widget '%s' from tree."), *PathInfo.WidgetName); + + if (!SetSubsystem->DeleteWidget(WidgetBlueprint, PathInfo.WidgetName)) + { + OutError = FString::Printf(TEXT("remove failed: elimination of widget '%s' did not succeed."), *PathInfo.WidgetName); + return false; + } + + return true; +} + +FString UUmgPatchSubsystem::ApplyPatch( + UWidgetBlueprint* WidgetBlueprint, + const TArray>& PatchOps, + TOptional ExpectedRevision) +{ + TSharedPtr Result = MakeShared(); + + if (!WidgetBlueprint) + { + Result->SetBoolField(TEXT("success"), false); + Result->SetStringField(TEXT("error"), TEXT("No active WidgetBlueprint.")); + } + else if (PatchOps.Num() == 0) + { + Result->SetBoolField(TEXT("success"), false); + Result->SetStringField(TEXT("error"), TEXT("Patch array is empty.")); + } + else + { + UUmgAttentionSubsystem* Attention = GEditor ? GEditor->GetEditorSubsystem() : nullptr; + bool bRevisionOk = true; + if (ExpectedRevision.IsSet()) + { + const int32 CurrentRevision = Attention ? Attention->GetPatchRevision() : 0; + bRevisionOk = Attention && CurrentRevision == ExpectedRevision.GetValue(); + if (!bRevisionOk) + { + Result->SetBoolField(TEXT("success"), false); + Result->SetStringField(TEXT("error"), TEXT("Patch revision mismatch.")); + Result->SetNumberField(TEXT("expected_revision"), ExpectedRevision.GetValue()); + Result->SetNumberField(TEXT("current_revision"), CurrentRevision); + } + } + + if (bRevisionOk) + { + FScopedTransaction Transaction(NSLOCTEXT("UmgMcp", "ApplyPatchTransaction", "Apply UMG Patch")); + WidgetBlueprint->Modify(); + + TArray> AppliedOps; + bool bAllSucceeded = true; + FString FirstError; + + for (int32 OpIndex = 0; OpIndex < PatchOps.Num(); ++OpIndex) + { + const TSharedPtr* OpObject = nullptr; + if (!PatchOps[OpIndex].IsValid() || !PatchOps[OpIndex]->TryGetObject(OpObject) || !OpObject || !(*OpObject).IsValid()) + { + bAllSucceeded = false; + FirstError = FString::Printf(TEXT("Op %d is not a JSON object."), OpIndex); + break; + } + + FString OpType; + FString Path; + (*OpObject)->TryGetStringField(TEXT("op"), OpType); + (*OpObject)->TryGetStringField(TEXT("path"), Path); + + FPatchPathInfo PathInfo; + FString PathError; + if (!ParsePatchPath(Path, OpType, PathInfo, PathError)) + { + bAllSucceeded = false; + FirstError = FString::Printf(TEXT("Op %d has invalid path: %s"), OpIndex, *PathError); + break; + } + + FString OpError; + bool bOpOk = false; + const TSharedPtr ValueField = (*OpObject)->TryGetField(TEXT("value")); + + if (OpType == TEXT("set")) + { + bOpOk = ApplySetOp(WidgetBlueprint, PathInfo, ValueField, OpError); + } + else if (OpType == TEXT("add")) + { + bOpOk = ApplyAddOp(WidgetBlueprint, PathInfo, ValueField, OpError); + } + else if (OpType == TEXT("remove")) + { + bOpOk = ApplyRemoveOp(WidgetBlueprint, PathInfo, OpError); + } + else + { + OpError = FString::Printf(TEXT("Unsupported op '%s'."), *OpType); + } + + if (!bOpOk) + { + bAllSucceeded = false; + FirstError = FString::Printf(TEXT("Op %d (%s): %s"), OpIndex, *OpType, *OpError); + break; + } + + AppliedOps.Add(PatchOps[OpIndex]); + } + + if (bAllSucceeded) + { + if (Attention) + { + const int32 NewRevision = Attention->IncrementPatchRevision(); + Result->SetNumberField(TEXT("revision"), NewRevision); + } + + Result->SetBoolField(TEXT("success"), true); + Result->SetArrayField(TEXT("applied"), AppliedOps); + Result->SetNumberField(TEXT("op_count"), AppliedOps.Num()); + } + else + { + Transaction.Cancel(); + Result->SetBoolField(TEXT("success"), false); + Result->SetStringField(TEXT("error"), FirstError); + Result->SetStringField(TEXT("rollback"), TEXT("Transaction cancelled; no partial patch applied.")); + } + } + } + + FString Out; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Out); + FJsonSerializer::Serialize(Result.ToSharedRef(), Writer); + return Out; +} diff --git a/Source/UmgMcp/Private/Preview/UmgPreviewRenderUtils.cpp b/Source/UmgMcp/Private/Preview/UmgPreviewRenderUtils.cpp new file mode 100644 index 0000000..ee7dfe2 --- /dev/null +++ b/Source/UmgMcp/Private/Preview/UmgPreviewRenderUtils.cpp @@ -0,0 +1,106 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#include "Preview/UmgPreviewRenderUtils.h" + +#include "Blueprint/UserWidget.h" +#include "Blueprint/WidgetTree.h" +#include "Components/Widget.h" +#include "Engine/LocalPlayer.h" +#include "Framework/Application/SlateApplication.h" +#include "Kismet2/KismetEditorUtilities.h" +#include "Widgets/SVirtualWindow.h" +#include "WidgetBlueprint.h" + +void FUmgPreviewRenderUtils::CompileWidgetBlueprint(UWidgetBlueprint* WidgetBlueprint) +{ + if (!WidgetBlueprint) + { + return; + } + + FKismetEditorUtilities::CompileBlueprint(WidgetBlueprint); +} + +UUserWidget* FUmgPreviewRenderUtils::CreatePreviewInstance( + UWidgetBlueprint* WidgetBlueprint, + UWorld* World, + FString& OutError) +{ + OutError.Reset(); + + if (!WidgetBlueprint) + { + OutError = TEXT("WidgetBlueprint is null."); + return nullptr; + } + + if (!WidgetBlueprint->GeneratedClass) + { + CompileWidgetBlueprint(WidgetBlueprint); + } + + UClass* WidgetClass = WidgetBlueprint->GeneratedClass; + if (!WidgetClass) + { + OutError = TEXT("Blueprint has no GeneratedClass. Compile the Widget Blueprint first."); + return nullptr; + } + + if (!World) + { + OutError = TEXT("Editor world is unavailable."); + return nullptr; + } + + if (!WidgetBlueprint->WidgetTree || !WidgetBlueprint->WidgetTree->RootWidget) + { + OutError = TEXT("Widget tree has no root widget."); + return nullptr; + } + + UUserWidget* PreviewWidget = NewObject(World, WidgetClass, NAME_None, RF_Transient); + if (!PreviewWidget) + { + OutError = TEXT("Failed to create preview widget instance."); + return nullptr; + } + + PreviewWidget->ClearFlags(RF_Transactional); + PreviewWidget->SetFlags(RF_Transient); + PreviewWidget->SetDesignerFlags(EWidgetDesignFlags::Designing | EWidgetDesignFlags::ExecutePreConstruct); + + if (ULocalPlayer* LocalPlayer = World->GetFirstLocalPlayerFromController()) + { + PreviewWidget->SetPlayerContext(FLocalPlayerContext(LocalPlayer)); + } + + PreviewWidget->Initialize(); + PreviewWidget->TakeWidget(); + return PreviewWidget; +} + +TSharedRef FUmgPreviewRenderUtils::PrepareOffscreenRenderTree( + TSharedRef Content, + const FVector2D& DrawSize, + TSharedPtr& OutVirtualWindow) +{ + OutVirtualWindow = SNew(SVirtualWindow).Size(DrawSize); + OutVirtualWindow->SetContent(Content); + OutVirtualWindow->SlatePrepass(1.0f); + + if (FSlateApplication::IsInitialized()) + { + FSlateApplication::Get().RegisterVirtualWindow(OutVirtualWindow.ToSharedRef()); + } + + return OutVirtualWindow.ToSharedRef(); +} + +void FUmgPreviewRenderUtils::ReleaseOffscreenRenderHost(TSharedPtr& VirtualWindow) +{ + if (VirtualWindow.IsValid() && FSlateApplication::IsInitialized()) + { + FSlateApplication::Get().UnregisterVirtualWindow(VirtualWindow.ToSharedRef()); + } + + VirtualWindow.Reset(); +} diff --git a/Source/UmgMcp/Private/Storybook/UmgMcpStorybookCommands.cpp b/Source/UmgMcp/Private/Storybook/UmgMcpStorybookCommands.cpp new file mode 100644 index 0000000..8eb5ccf --- /dev/null +++ b/Source/UmgMcp/Private/Storybook/UmgMcpStorybookCommands.cpp @@ -0,0 +1,154 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#include "Storybook/UmgMcpStorybookCommands.h" + +#include "Bridge/UmgMcpCommonUtils.h" +#include "Editor.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" +#include "Storybook/UmgStorybookSubsystem.h" +#include "WidgetBlueprint.h" + +int32 FUmgMcpStorybookCommands::ResolveViewportDimension( + const TSharedPtr& Params, + const TCHAR* PrimaryKey, + const TCHAR* AlternateKey, + int32 DefaultValue) +{ + if (!Params.IsValid()) + { + return DefaultValue; + } + + double NumericValue = 0.0; + if (Params->TryGetNumberField(PrimaryKey, NumericValue)) + { + return static_cast(NumericValue); + } + if (Params->TryGetNumberField(AlternateKey, NumericValue)) + { + return static_cast(NumericValue); + } + return DefaultValue; +} + +TSharedPtr FUmgMcpStorybookCommands::HandleCommand(const FString& CommandType, const TSharedPtr& Params) +{ + TSharedPtr Response = MakeShared(); + + if (!GEditor) + { + Response->SetBoolField(TEXT("success"), false); + Response->SetStringField(TEXT("error"), TEXT("GEditor not available.")); + return Response; + } + + FString ErrorMessage; + UWidgetBlueprint* TargetBlueprint = FUmgMcpCommonUtils::GetTargetWidgetBlueprint(Params, ErrorMessage); + if (!TargetBlueprint) + { + Response->SetBoolField(TEXT("success"), false); + Response->SetStringField(TEXT("error"), ErrorMessage); + return Response; + } + + UUmgStorybookSubsystem* StorybookSubsystem = GEditor->GetEditorSubsystem(); + if (!StorybookSubsystem) + { + Response->SetBoolField(TEXT("success"), false); + Response->SetStringField(TEXT("error"), TEXT("UmgStorybookSubsystem is unavailable.")); + return Response; + } + + auto DeserializeSubsystemJson = [&Response](const FString& JsonString) -> bool + { + TSharedPtr ParsedObject; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonString); + if (!FJsonSerializer::Deserialize(Reader, ParsedObject) || !ParsedObject.IsValid()) + { + Response->SetBoolField(TEXT("success"), false); + Response->SetStringField(TEXT("error"), TEXT("Failed to parse storybook subsystem response.")); + return false; + } + + for (const auto& Field : ParsedObject->Values) + { + Response->SetField(Field.Key.ToView(), Field.Value); + } + return true; + }; + + if (CommandType == TEXT("render_widget_preview")) + { + FString WidgetName; + // Only render a subtree when widget_name is explicitly provided in the request. + // Do not inherit Active Widget scope — that breaks full-screen AI screenshots. + Params->TryGetStringField(TEXT("widget_name"), WidgetName); + + FString Theme; + Params->TryGetStringField(TEXT("theme"), Theme); + + const int32 ViewportWidth = ResolveViewportDimension(Params, TEXT("viewport_w"), TEXT("viewport_width"), 400); + const int32 ViewportHeight = ResolveViewportDimension(Params, TEXT("viewport_h"), TEXT("viewport_height"), 300); + const int32 ResolvedWidth = ResolveViewportDimension(Params, TEXT("width"), TEXT("width"), ViewportWidth); + const int32 ResolvedHeight = ResolveViewportDimension(Params, TEXT("height"), TEXT("height"), ViewportHeight); + + const FString ResultJson = StorybookSubsystem->RenderWidgetPreview( + TargetBlueprint, + WidgetName, + ResolvedWidth, + ResolvedHeight, + Theme); + DeserializeSubsystemJson(ResultJson); + } + else if (CommandType == TEXT("storybook_list_variants")) + { + FString ParentWidgetName; + FString CatalogComponentId; + Params->TryGetStringField(TEXT("parent_widget"), ParentWidgetName); + Params->TryGetStringField(TEXT("catalog_component_id"), CatalogComponentId); + if (CatalogComponentId.IsEmpty()) + { + Params->TryGetStringField(TEXT("component_id"), CatalogComponentId); + } + + const FString ResultJson = StorybookSubsystem->ListVariants(TargetBlueprint, ParentWidgetName, CatalogComponentId); + DeserializeSubsystemJson(ResultJson); + } + else if (CommandType == TEXT("storybook_render")) + { + TArray WidgetNames; + const TArray>* WidgetNamesArray = nullptr; + if (Params->TryGetArrayField(TEXT("widget_names"), WidgetNamesArray)) + { + for (const TSharedPtr& Value : *WidgetNamesArray) + { + FString WidgetName = Value->AsString(); + if (!WidgetName.IsEmpty()) + { + WidgetNames.Add(WidgetName); + } + } + } + + FString Theme; + Params->TryGetStringField(TEXT("theme"), Theme); + + const int32 ViewportWidth = ResolveViewportDimension(Params, TEXT("viewport_w"), TEXT("viewport_width"), 400); + const int32 ViewportHeight = ResolveViewportDimension(Params, TEXT("viewport_h"), TEXT("viewport_height"), 300); + + const FString ResultJson = StorybookSubsystem->RenderVariants( + TargetBlueprint, + WidgetNames, + ViewportWidth, + ViewportHeight, + Theme); + DeserializeSubsystemJson(ResultJson); + } + else + { + Response->SetBoolField(TEXT("success"), false); + Response->SetStringField(TEXT("error"), FString::Printf(TEXT("Unknown storybook command: %s"), *CommandType)); + } + + return Response; +} diff --git a/Source/UmgMcp/Private/Storybook/UmgStorybookSubsystem.cpp b/Source/UmgMcp/Private/Storybook/UmgStorybookSubsystem.cpp new file mode 100644 index 0000000..bea5d39 --- /dev/null +++ b/Source/UmgMcp/Private/Storybook/UmgStorybookSubsystem.cpp @@ -0,0 +1,588 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#include "Storybook/UmgStorybookSubsystem.h" + +#include "WidgetBlueprint.h" +#include "Blueprint/UserWidget.h" +#include "Blueprint/WidgetTree.h" +#include "Components/PanelWidget.h" +#include "Components/Widget.h" +#include "Editor.h" +#include "Engine/LocalPlayer.h" +#include "Engine/TextureRenderTarget2D.h" +#include "Framework/Application/SlateApplication.h" +#include "HAL/FileManager.h" +#include "IImageWrapper.h" +#include "IImageWrapperModule.h" +#include "ImageUtils.h" +#include "Kismet2/KismetEditorUtilities.h" +#include "Misc/Base64.h" +#include "Misc/FileHelper.h" +#include "Interfaces/IPluginManager.h" +#include "Misc/Paths.h" +#include "Modules/ModuleManager.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" +#include "Preview/UmgPreviewRenderUtils.h" +#include "Slate/WidgetRenderer.h" +#include "Widgets/SVirtualWindow.h" +#include "TextureResource.h" + +DEFINE_LOG_CATEGORY(LogUmgStorybook); + +namespace UmgStorybookInternal +{ + static constexpr int32 MinViewportSize = 16; + static constexpr int32 MaxViewportSize = 4096; + + static int32 ClampViewportSize(int32 Value, int32 DefaultValue) + { + if (Value <= 0) + { + return DefaultValue; + } + return FMath::Clamp(Value, MinViewportSize, MaxViewportSize); + } + + static TSharedPtr MakeErrorObject(const FString& Message) + { + TSharedPtr Result = MakeShared(); + Result->SetBoolField(TEXT("success"), false); + Result->SetStringField(TEXT("status"), TEXT("error")); + Result->SetStringField(TEXT("error"), Message); + return Result; + } + + static FString SerializeJsonObject(const TSharedPtr& JsonObject) + { + FString Output; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Output); + FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer); + return Output; + } + + static UWidget* FindWidgetByName(UUserWidget* UserWidget, const FString& WidgetName) + { + if (!UserWidget || WidgetName.IsEmpty()) + { + return nullptr; + } + + if (UWidgetTree* Tree = UserWidget->WidgetTree) + { + return Tree->FindWidget(FName(*WidgetName)); + } + + return nullptr; + } + + static TSharedPtr ResolveRenderSlate(UUserWidget* UserWidget, const FString& WidgetName, FString& OutResolvedWidgetName, FString& OutError) + { + if (!UserWidget) + { + OutError = TEXT("Preview widget instance is null."); + return nullptr; + } + + UserWidget->SetDesignerFlags(EWidgetDesignFlags::Designing | EWidgetDesignFlags::ExecutePreConstruct); + UserWidget->Initialize(); + + if (UWorld* World = UserWidget->GetWorld()) + { + if (ULocalPlayer* LocalPlayer = World->GetFirstLocalPlayerFromController()) + { + UserWidget->SetPlayerContext(FLocalPlayerContext(LocalPlayer)); + } + } + + TSharedRef SlateWidget = UserWidget->TakeWidget(); + + if (!WidgetName.IsEmpty()) + { + if (UWidget* TargetWidget = FindWidgetByName(UserWidget, WidgetName)) + { + OutResolvedWidgetName = TargetWidget->GetName(); + if (TSharedPtr TargetSlate = TargetWidget->GetCachedWidget()) + { + return TargetSlate; + } + + // Off-screen CreateWidget often only exposes UserWidget-level Slate reliably. + UE_LOG(LogUmgStorybook, Warning, + TEXT("Preview: widget '%s' has no direct Slate cache; falling back to UserWidget root for screenshot."), + *WidgetName); + + if (UWidget* RootWidget = UserWidget->WidgetTree ? UserWidget->WidgetTree->RootWidget : nullptr) + { + OutResolvedWidgetName = RootWidget->GetName(); + } + + if (TSharedPtr RootSlate = UserWidget->GetCachedWidget()) + { + return RootSlate; + } + + return SlateWidget; + } + + OutError = FString::Printf(TEXT("Widget '%s' not found in preview instance."), *WidgetName); + return nullptr; + } + + if (UWidget* RootWidget = UserWidget->WidgetTree ? UserWidget->WidgetTree->RootWidget : nullptr) + { + OutResolvedWidgetName = RootWidget->GetName(); + } + else + { + OutResolvedWidgetName = UserWidget->GetName(); + } + + if (TSharedPtr RootSlate = UserWidget->GetCachedWidget()) + { + return RootSlate; + } + + return SlateWidget; + } +} + +void UUmgStorybookSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + UE_LOG(LogUmgStorybook, Log, TEXT("UmgStorybookSubsystem initialized.")); +} + +void UUmgStorybookSubsystem::Deinitialize() +{ + UE_LOG(LogUmgStorybook, Log, TEXT("UmgStorybookSubsystem deinitialized.")); + Super::Deinitialize(); +} + +FString UUmgStorybookSubsystem::GetCatalogPath() const +{ + if (const TSharedPtr Plugin = IPluginManager::Get().FindPlugin(TEXT("UmgMcp"))) + { + return FPaths::Combine(Plugin->GetBaseDir(), TEXT("Resources/storybook_catalog.json")); + } + + return FPaths::ConvertRelativePathToFull( + FPaths::Combine(FPaths::ProjectPluginsDir(), TEXT("UnrealMotionGraphicsMCP-main/Resources/storybook_catalog.json"))); +} + +bool UUmgStorybookSubsystem::LoadCatalogVariants(const FString& CatalogComponentId, const FString& AssetPath, TArray& OutVariants, FString& OutError) const +{ + const FString CatalogPath = GetCatalogPath(); + if (!FPaths::FileExists(CatalogPath)) + { + OutError = FString::Printf(TEXT("Catalog file not found: %s"), *CatalogPath); + return false; + } + + FString CatalogContent; + if (!FFileHelper::LoadFileToString(CatalogContent, *CatalogPath)) + { + OutError = FString::Printf(TEXT("Failed to read catalog file: %s"), *CatalogPath); + return false; + } + + TSharedPtr CatalogJson; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(CatalogContent); + if (!FJsonSerializer::Deserialize(Reader, CatalogJson) || !CatalogJson.IsValid()) + { + OutError = TEXT("Failed to parse storybook_catalog.json."); + return false; + } + + const TArray>* ComponentsArray = nullptr; + if (!CatalogJson->TryGetArrayField(TEXT("components"), ComponentsArray)) + { + OutError = TEXT("storybook_catalog.json is missing 'components' array."); + return false; + } + + for (const TSharedPtr& ComponentValue : *ComponentsArray) + { + const TSharedPtr* ComponentObject = nullptr; + if (!ComponentValue->TryGetObject(ComponentObject) || !ComponentObject || !ComponentObject->IsValid()) + { + continue; + } + + FString ComponentId; + FString ComponentAssetPath; + (*ComponentObject)->TryGetStringField(TEXT("id"), ComponentId); + (*ComponentObject)->TryGetStringField(TEXT("asset_path"), ComponentAssetPath); + + const bool bIdMatch = !CatalogComponentId.IsEmpty() && ComponentId == CatalogComponentId; + const bool bAssetMatch = CatalogComponentId.IsEmpty() && !AssetPath.IsEmpty() && ComponentAssetPath == AssetPath; + if (!bIdMatch && !bAssetMatch) + { + continue; + } + + const TArray>* VariantsArray = nullptr; + if ((*ComponentObject)->TryGetArrayField(TEXT("variants"), VariantsArray)) + { + for (const TSharedPtr& VariantValue : *VariantsArray) + { + FString VariantName; + const TSharedPtr* VariantObject = nullptr; + if (VariantValue->TryGetObject(VariantObject) && VariantObject && VariantObject->IsValid()) + { + if ((*VariantObject)->TryGetStringField(TEXT("widget_name"), VariantName) && !VariantName.IsEmpty()) + { + OutVariants.Add(VariantName); + } + } + else if (VariantValue->TryGetString(VariantName) && !VariantName.IsEmpty()) + { + OutVariants.Add(VariantName); + } + } + } + + if (OutVariants.Num() > 0) + { + return true; + } + } + + OutError = CatalogComponentId.IsEmpty() + ? FString::Printf(TEXT("No catalog variants found for asset '%s'."), *AssetPath) + : FString::Printf(TEXT("No catalog variants found for catalog component '%s'."), *CatalogComponentId); + return false; +} + +bool UUmgStorybookSubsystem::EncodeRenderTargetToBase64Png(UTextureRenderTarget2D* RenderTarget, FString& OutBase64, FString& OutError) const +{ + if (!RenderTarget) + { + OutError = TEXT("Render target is null."); + return false; + } + + FTextureRenderTargetResource* RenderTargetResource = RenderTarget->GameThread_GetRenderTargetResource(); + if (!RenderTargetResource) + { + OutError = TEXT("Render target resource is unavailable."); + return false; + } + + TArray Pixels; + if (!RenderTargetResource->ReadPixels(Pixels)) + { + OutError = TEXT("Failed to read pixels from render target."); + return false; + } + + const int32 Width = RenderTarget->SizeX; + const int32 Height = RenderTarget->SizeY; + if (Width <= 0 || Height <= 0 || Pixels.Num() != Width * Height) + { + OutError = TEXT("Render target pixel buffer size mismatch."); + return false; + } + + TArray RawData; + RawData.SetNumUninitialized(Width * Height * 4); + for (int32 Index = 0; Index < Pixels.Num(); ++Index) + { + const FColor& Color = Pixels[Index]; + RawData[Index * 4 + 0] = Color.R; + RawData[Index * 4 + 1] = Color.G; + RawData[Index * 4 + 2] = Color.B; + RawData[Index * 4 + 3] = Color.A; + } + + IImageWrapperModule& ImageWrapperModule = FModuleManager::LoadModuleChecked(TEXT("ImageWrapper")); + const TSharedPtr ImageWrapper = ImageWrapperModule.CreateImageWrapper(EImageFormat::PNG); + if (!ImageWrapper.IsValid() || !ImageWrapper->SetRaw(RawData.GetData(), RawData.Num(), Width, Height, ERGBFormat::RGBA, 8)) + { + OutError = TEXT("Failed to initialize PNG encoder."); + return false; + } + + const TArray64& CompressedData = ImageWrapper->GetCompressed(100); + if (CompressedData.Num() <= 0) + { + OutError = TEXT("PNG compression returned empty data."); + return false; + } + + OutBase64 = FBase64::Encode(CompressedData.GetData(), CompressedData.Num()); + return true; +} + +FString UUmgStorybookSubsystem::RenderWidgetPreview( + UWidgetBlueprint* WidgetBlueprint, + const FString& WidgetName, + int32 ViewportWidth, + int32 ViewportHeight, + const FString& Theme) +{ + using namespace UmgStorybookInternal; + + if (!WidgetBlueprint) + { + return SerializeJsonObject(MakeErrorObject(TEXT("WidgetBlueprint is null."))); + } + + ViewportWidth = ClampViewportSize(ViewportWidth, 400); + ViewportHeight = ClampViewportSize(ViewportHeight, 300); + + FUmgPreviewRenderUtils::CompileWidgetBlueprint(WidgetBlueprint); + + UClass* WidgetClass = WidgetBlueprint->GeneratedClass; + if (!WidgetClass) + { + return SerializeJsonObject(MakeErrorObject(TEXT("Blueprint has no GeneratedClass. Compile the Widget Blueprint first."))); + } + + UWorld* World = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr; + if (!World) + { + return SerializeJsonObject(MakeErrorObject(TEXT("Editor world is unavailable."))); + } + + if (!WidgetBlueprint->WidgetTree || !WidgetBlueprint->WidgetTree->RootWidget) + { + return SerializeJsonObject(MakeErrorObject( + TEXT("Widget tree has no root widget. Add a panel root (e.g. VerticalBox or CanvasPanel) before preview."))); + } + + UUserWidget* PreviewWidget = NewObject(World, WidgetClass, NAME_None, RF_Transient); + if (!PreviewWidget) + { + return SerializeJsonObject(MakeErrorObject(TEXT("Failed to create preview widget instance."))); + } + + PreviewWidget->ClearFlags(RF_Transactional); + + FString ResolvedWidgetName; + FString ResolveError; + TSharedPtr RenderSlate = ResolveRenderSlate(PreviewWidget, WidgetName, ResolvedWidgetName, ResolveError); + if (!RenderSlate.IsValid()) + { + PreviewWidget->ConditionalBeginDestroy(); + return SerializeJsonObject(MakeErrorObject(ResolveError)); + } + + const FVector2D DrawSize(static_cast(ViewportWidth), static_cast(ViewportHeight)); + + TSharedPtr VirtualHost; + const TSharedRef RenderTree = FUmgPreviewRenderUtils::PrepareOffscreenRenderTree( + RenderSlate.ToSharedRef(), + DrawSize, + VirtualHost); + + UTextureRenderTarget2D* RenderTarget = NewObject(GetTransientPackage()); + RenderTarget->RenderTargetFormat = RTF_RGBA8; + RenderTarget->ClearColor = FLinearColor(0.f, 0.f, 0.f, 0.f); + RenderTarget->InitAutoFormat(ViewportWidth, ViewportHeight); + RenderTarget->UpdateResourceImmediate(true); + + FWidgetRenderer WidgetRenderer(true); + WidgetRenderer.DrawWidget(RenderTarget, RenderTree, DrawSize, 0.f, false); + FlushRenderingCommands(); + + FUmgPreviewRenderUtils::ReleaseOffscreenRenderHost(VirtualHost); + + FString Base64Png; + FString EncodeError; + if (!EncodeRenderTargetToBase64Png(RenderTarget, Base64Png, EncodeError)) + { + PreviewWidget->ConditionalBeginDestroy(); + return SerializeJsonObject(MakeErrorObject(EncodeError)); + } + + const FString AssetPath = WidgetBlueprint->GetPathName(); + const FString WidgetPath = AssetPath + TEXT(".") + ResolvedWidgetName; + + TSharedPtr Result = MakeShared(); + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("status"), TEXT("ok")); + Result->SetStringField(TEXT("image_base64"), Base64Png); + Result->SetNumberField(TEXT("width"), ViewportWidth); + Result->SetNumberField(TEXT("height"), ViewportHeight); + Result->SetStringField(TEXT("widget_path"), WidgetPath); + Result->SetStringField(TEXT("asset_path"), AssetPath); + Result->SetStringField(TEXT("widget_name"), ResolvedWidgetName); + + if (!Theme.IsEmpty()) + { + Result->SetStringField(TEXT("theme"), Theme); + Result->SetStringField(TEXT("theme_note"), TEXT("Theme application is not implemented yet; value returned as metadata only.")); + } + + Result->SetStringField( + TEXT("runtime_note"), + TEXT("Off-screen preview uses TakeWidget + SVirtualWindow + SlatePrepass; Designer does not need to be open. BindWidget/ViewModel/PIE-only logic may still render empty.")); + + PreviewWidget->ConditionalBeginDestroy(); + return SerializeJsonObject(Result); +} + +FString UUmgStorybookSubsystem::ListVariants( + UWidgetBlueprint* WidgetBlueprint, + const FString& ParentWidgetName, + const FString& CatalogComponentId) +{ + using namespace UmgStorybookInternal; + + if (!WidgetBlueprint) + { + return SerializeJsonObject(MakeErrorObject(TEXT("WidgetBlueprint is null."))); + } + + if (!WidgetBlueprint->WidgetTree) + { + return SerializeJsonObject(MakeErrorObject(TEXT("WidgetTree is null."))); + } + + TArray VariantNames; + FString CatalogError; + const bool bCatalogRequested = !CatalogComponentId.IsEmpty(); + + if (bCatalogRequested) + { + if (!LoadCatalogVariants(CatalogComponentId, WidgetBlueprint->GetPathName(), VariantNames, CatalogError)) + { + return SerializeJsonObject(MakeErrorObject(CatalogError)); + } + + if (VariantNames.Num() == 0) + { + return SerializeJsonObject(MakeErrorObject( + FString::Printf(TEXT("Catalog component '%s' matched but defines no variants."), *CatalogComponentId))); + } + } + else if (LoadCatalogVariants(CatalogComponentId, WidgetBlueprint->GetPathName(), VariantNames, CatalogError)) + { + // Optional asset-path catalog match succeeded. + } + else + { + VariantNames.Reset(); + } + + if (VariantNames.Num() == 0) + { + UWidget* ParentWidget = nullptr; + if (!ParentWidgetName.IsEmpty()) + { + ParentWidget = WidgetBlueprint->WidgetTree->FindWidget(FName(*ParentWidgetName)); + if (!ParentWidget) + { + return SerializeJsonObject(MakeErrorObject(FString::Printf(TEXT("Parent widget '%s' not found."), *ParentWidgetName))); + } + } + else + { + ParentWidget = WidgetBlueprint->WidgetTree->RootWidget; + } + + if (UPanelWidget* PanelWidget = Cast(ParentWidget)) + { + const int32 ChildCount = PanelWidget->GetChildrenCount(); + for (int32 ChildIndex = 0; ChildIndex < ChildCount; ++ChildIndex) + { + if (UWidget* ChildWidget = PanelWidget->GetChildAt(ChildIndex)) + { + VariantNames.Add(ChildWidget->GetName()); + } + } + } + else if (ParentWidget) + { + VariantNames.Add(ParentWidget->GetName()); + } + } + + TArray> VariantJsonArray; + for (const FString& VariantName : VariantNames) + { + TSharedPtr VariantObject = MakeShared(); + VariantObject->SetStringField(TEXT("widget_name"), VariantName); + VariantJsonArray.Add(MakeShared(VariantObject)); + } + + TSharedPtr Result = MakeShared(); + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("status"), TEXT("ok")); + Result->SetStringField(TEXT("asset_path"), WidgetBlueprint->GetPathName()); + Result->SetArrayField(TEXT("variants"), VariantJsonArray); + Result->SetNumberField(TEXT("count"), VariantNames.Num()); + if (!CatalogComponentId.IsEmpty()) + { + Result->SetStringField(TEXT("catalog_component_id"), CatalogComponentId); + } + return SerializeJsonObject(Result); +} + +FString UUmgStorybookSubsystem::RenderVariants( + UWidgetBlueprint* WidgetBlueprint, + const TArray& WidgetNames, + int32 ViewportWidth, + int32 ViewportHeight, + const FString& Theme) +{ + using namespace UmgStorybookInternal; + + if (!WidgetBlueprint) + { + return SerializeJsonObject(MakeErrorObject(TEXT("WidgetBlueprint is null."))); + } + + TArray NamesToRender = WidgetNames; + if (NamesToRender.Num() == 0) + { + FString ListJson = ListVariants(WidgetBlueprint, FString(), FString()); + TSharedPtr ListObject; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(ListJson); + if (FJsonSerializer::Deserialize(Reader, ListObject) && ListObject.IsValid()) + { + const TArray>* VariantsArray = nullptr; + if (ListObject->TryGetArrayField(TEXT("variants"), VariantsArray)) + { + for (const TSharedPtr& VariantValue : *VariantsArray) + { + const TSharedPtr* VariantObject = nullptr; + if (VariantValue->TryGetObject(VariantObject) && VariantObject && VariantObject->IsValid()) + { + FString VariantName; + if ((*VariantObject)->TryGetStringField(TEXT("widget_name"), VariantName)) + { + NamesToRender.Add(VariantName); + } + } + } + } + } + } + + if (NamesToRender.Num() == 0) + { + return SerializeJsonObject(MakeErrorObject(TEXT("No variants to render. Provide widget_names or ensure the widget tree has children."))); + } + + TArray> RenderResults; + for (const FString& VariantName : NamesToRender) + { + const FString PreviewJson = RenderWidgetPreview(WidgetBlueprint, VariantName, ViewportWidth, ViewportHeight, Theme); + TSharedPtr PreviewObject; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(PreviewJson); + if (FJsonSerializer::Deserialize(Reader, PreviewObject) && PreviewObject.IsValid()) + { + RenderResults.Add(MakeShared(PreviewObject)); + } + } + + TSharedPtr Result = MakeShared(); + Result->SetBoolField(TEXT("success"), true); + Result->SetStringField(TEXT("status"), TEXT("ok")); + Result->SetStringField(TEXT("asset_path"), WidgetBlueprint->GetPathName()); + Result->SetArrayField(TEXT("renders"), RenderResults); + Result->SetNumberField(TEXT("count"), RenderResults.Num()); + return SerializeJsonObject(Result); +} diff --git a/Source/UmgMcp/Private/Theme/UmgThemeSubsystem.cpp b/Source/UmgMcp/Private/Theme/UmgThemeSubsystem.cpp new file mode 100644 index 0000000..84bca2e --- /dev/null +++ b/Source/UmgMcp/Private/Theme/UmgThemeSubsystem.cpp @@ -0,0 +1,378 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#include "Theme/UmgThemeSubsystem.h" + +#include "Dom/JsonObject.h" +#include "Dom/JsonValue.h" +#include "Interfaces/IPluginManager.h" +#include "Misc/FileHelper.h" +#include "Misc/PackageName.h" +#include "Misc/Paths.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" + +DEFINE_LOG_CATEGORY(LogUmgTheme); + +namespace UmgThemeInternal +{ + static FString JsonValueToResolveString(const TSharedPtr& Value) + { + if (!Value.IsValid()) + { + return FString(); + } + + switch (Value->Type) + { + case EJson::String: + return Value->AsString(); + case EJson::Number: + return LexToString(Value->AsNumber()); + case EJson::Boolean: + return Value->AsBool() ? TEXT("true") : TEXT("false"); + default: + break; + } + + FString Out; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Out); + FJsonSerializer::Serialize(Value.ToSharedRef(), TEXT(""), Writer); + return Out; + } + + static void DeepMergeJsonObject(const TSharedPtr& Patch, TSharedPtr& Target) + { + if (!Patch.IsValid() || !Target.IsValid()) + { + return; + } + + for (const TPair>& Field : Patch->Values) + { + const TSharedPtr* PatchChildObject = nullptr; + if (Field.Value->TryGetObject(PatchChildObject) && PatchChildObject && (*PatchChildObject).IsValid()) + { + TSharedPtr TargetChild; + const TSharedPtr* TargetChildObject = nullptr; + if (Target->TryGetObjectField(Field.Key, TargetChildObject) && TargetChildObject && (*TargetChildObject).IsValid()) + { + TargetChild = *TargetChildObject; + } + else + { + TargetChild = MakeShared(); + Target->SetObjectField(Field.Key, TargetChild); + } + DeepMergeJsonObject(*PatchChildObject, TargetChild); + } + else + { + Target->SetField(Field.Key, Field.Value); + } + } + } +} + +void UUmgThemeSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + ActiveThemeAssetPath = GetDefaultThemeAssetPath(); + ReloadThemeCache(); + UE_LOG(LogUmgTheme, Log, TEXT("UmgThemeSubsystem initialized. Active theme: %s"), *ActiveThemeAssetPath); +} + +void UUmgThemeSubsystem::Deinitialize() +{ + CachedTheme.Reset(); + Super::Deinitialize(); +} + +FString UUmgThemeSubsystem::GetDefaultThemeAssetPath() const +{ + return TEXT("/Game/UI/theme"); +} + +FString UUmgThemeSubsystem::ResolveThemeDiskPath(const FString& OptionalAssetPath) const +{ + const FString AssetPath = OptionalAssetPath.IsEmpty() ? ActiveThemeAssetPath : OptionalAssetPath; + const FString PackagePath = AssetPath.Contains(TEXT(".")) ? FPaths::GetPath(AssetPath) : AssetPath; + return FPackageName::LongPackageNameToFilename(PackagePath, TEXT(".json")); +} + +bool UUmgThemeSubsystem::LoadThemeFromDisk(const FString& DiskPath) +{ + FString FileContent; + if (!FPaths::FileExists(DiskPath) || !FFileHelper::LoadFileToString(FileContent, *DiskPath)) + { + if (const TSharedPtr Plugin = IPluginManager::Get().FindPlugin(TEXT("UmgMcp"))) + { + const FString DefaultPath = FPaths::Combine(Plugin->GetBaseDir(), TEXT("Resources/theme_default.json")); + if (FFileHelper::LoadFileToString(FileContent, *DefaultPath)) + { + UE_LOG(LogUmgTheme, Log, TEXT("Theme file missing at '%s'; using plugin default."), *DiskPath); + } + else + { + UE_LOG(LogUmgTheme, Warning, TEXT("No theme file at '%s' and no plugin default."), *DiskPath); + CachedTheme = MakeShared(); + return false; + } + } + else + { + CachedTheme = MakeShared(); + return false; + } + } + + TSharedPtr Parsed; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(FileContent); + if (!FJsonSerializer::Deserialize(Reader, Parsed) || !Parsed.IsValid()) + { + UE_LOG(LogUmgTheme, Error, TEXT("Failed to parse theme JSON: %s"), *DiskPath); + CachedTheme = MakeShared(); + return false; + } + + CachedTheme = Parsed; + return true; +} + +bool UUmgThemeSubsystem::ReloadThemeCache(const FString& OptionalAssetPath) +{ + if (!OptionalAssetPath.IsEmpty()) + { + ActiveThemeAssetPath = OptionalAssetPath.Contains(TEXT(".")) + ? FPaths::GetPath(OptionalAssetPath) + : OptionalAssetPath; + } + + const FString DiskPath = ResolveThemeDiskPath(OptionalAssetPath); + return LoadThemeFromDisk(DiskPath); +} + +FString UUmgThemeSubsystem::GetThemeJsonString() const +{ + if (!CachedTheme.IsValid()) + { + return TEXT("{}"); + } + + FString Out; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Out); + FJsonSerializer::Serialize(CachedTheme.ToSharedRef(), Writer); + return Out; +} + +TSharedPtr UUmgThemeSubsystem::NavigateTokenPath(const FString& DotPath) const +{ + if (!CachedTheme.IsValid() || DotPath.IsEmpty()) + { + return nullptr; + } + + TArray Segments; + DotPath.ParseIntoArray(Segments, TEXT("."), true); + const TSharedPtr* CurrentObject = &CachedTheme; + + for (int32 Index = 0; Index < Segments.Num(); ++Index) + { + if (!CurrentObject || !(*CurrentObject).IsValid()) + { + return nullptr; + } + + if (Index == Segments.Num() - 1) + { + return (*CurrentObject)->TryGetField(Segments[Index]); + } + + const TSharedPtr* NextObject = nullptr; + if (!(*CurrentObject)->TryGetObjectField(Segments[Index], NextObject)) + { + return nullptr; + } + CurrentObject = NextObject; + } + + return nullptr; +} + +FString UUmgThemeSubsystem::ResolveToken(const FString& TokenRef) const +{ + if (const TSharedPtr ResolvedValue = ResolveTokenValue(TokenRef)) + { + return UmgThemeInternal::JsonValueToResolveString(ResolvedValue); + } + + FString Ref = TokenRef; + Ref.TrimStartAndEndInline(); + if (Ref.StartsWith(TEXT("@"))) + { + Ref = Ref.Mid(1); + } + if (!Ref.IsEmpty()) + { + UE_LOG(LogUmgTheme, Warning, TEXT("Unresolved theme token '@%s'"), *Ref); + } + return FString(); +} + +TSharedPtr UUmgThemeSubsystem::ResolveTokenValue(const FString& TokenRef) const +{ + FString Ref = TokenRef; + Ref.TrimStartAndEndInline(); + if (Ref.StartsWith(TEXT("@"))) + { + Ref = Ref.Mid(1); + } + + if (Ref.IsEmpty()) + { + return nullptr; + } + + if (TSharedPtr Found = NavigateTokenPath(Ref)) + { + FString Serialized; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Serialized); + FJsonSerializer::Serialize(Found.ToSharedRef(), TEXT(""), Writer); + + TSharedPtr Cloned; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(Serialized); + if (FJsonSerializer::Deserialize(Reader, Cloned) && Cloned.IsValid()) + { + return Cloned; + } + } + + UE_LOG(LogUmgTheme, Warning, TEXT("Unresolved theme token '@%s'"), *Ref); + return nullptr; +} + +void UUmgThemeSubsystem::ProcessJsonValue(TSharedPtr& Value, int32 Depth) const +{ + if (!Value.IsValid()) + { + return; + } + + if (Depth > MaxTokenProcessDepth) + { + UE_LOG(LogUmgTheme, Error, TEXT("ProcessJsonValue: exceeded max depth (%d). Aborting token resolution for this branch."), MaxTokenProcessDepth); + return; + } + + if (Value->Type == EJson::String) + { + const FString Str = Value->AsString(); + if (Str.StartsWith(TEXT("@"))) + { + if (const TSharedPtr ResolvedValue = ResolveTokenValue(Str)) + { + Value = ResolvedValue; + // Resolved value may itself contain nested @ tokens or objects. + ProcessJsonValue(Value, Depth + 1); + } + else + { + UE_LOG(LogUmgTheme, Warning, TEXT("ProcessJsonValue: failed to resolve token '%s'"), *Str); + } + } + return; + } + + if (Value->Type == EJson::Object) + { + TSharedPtr Obj = Value->AsObject(); + if (Obj.IsValid()) + { + ProcessJsonTokens(Obj, Depth + 1); + Value = MakeShared(Obj); + } + return; + } + + if (Value->Type == EJson::Array) + { + TArray> Items = Value->AsArray(); + for (TSharedPtr& Item : Items) + { + ProcessJsonValue(Item, Depth + 1); + } + Value = MakeShared(Items); + } +} + +void UUmgThemeSubsystem::ProcessJsonTokens(TSharedPtr& InOutJson, int32 Depth) const +{ + if (!InOutJson.IsValid()) + { + return; + } + + if (Depth > MaxTokenProcessDepth) + { + UE_LOG(LogUmgTheme, Error, TEXT("ProcessJsonTokens: exceeded max depth (%d). Aborting token resolution."), MaxTokenProcessDepth); + return; + } + + // Never mutate Values while iterating it — SetField can rehash and invalidate iterators. + TArray FieldKeys; + FieldKeys.Reserve(InOutJson->Values.Num()); + for (const TPair>& Pair : InOutJson->Values) + { + FieldKeys.Add(Pair.Key); + } + + for (const FString& FieldKey : FieldKeys) + { + TSharedPtr FieldValue = InOutJson->TryGetField(FieldKey); + if (!FieldValue.IsValid()) + { + UE_LOG(LogUmgTheme, Verbose, TEXT("ProcessJsonTokens: missing field '%s' during token pass (skipped)."), *FieldKey); + continue; + } + + ProcessJsonValue(FieldValue, Depth + 1); + InOutJson->SetField(FieldKey, FieldValue); + } +} + +bool UUmgThemeSubsystem::ApplyThemePatch(const TSharedPtr& PatchObject, const FString& OptionalAssetPath) +{ + if (!PatchObject.IsValid()) + { + return false; + } + + if (!OptionalAssetPath.IsEmpty()) + { + ActiveThemeAssetPath = OptionalAssetPath.Contains(TEXT(".")) + ? FPaths::GetPath(OptionalAssetPath) + : OptionalAssetPath; + } + + if (!CachedTheme.IsValid()) + { + CachedTheme = MakeShared(); + } + + UmgThemeInternal::DeepMergeJsonObject(PatchObject, CachedTheme); + + const FString DiskPath = ResolveThemeDiskPath(OptionalAssetPath); + IFileManager::Get().MakeDirectory(*FPaths::GetPath(DiskPath), true); + + FString OutJson; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&OutJson); + FJsonSerializer::Serialize(CachedTheme.ToSharedRef(), Writer); + + if (!FFileHelper::SaveStringToFile(OutJson, *DiskPath)) + { + UE_LOG(LogUmgTheme, Error, TEXT("Failed to write theme file: %s"), *DiskPath); + return false; + } + + UE_LOG(LogUmgTheme, Log, TEXT("Theme patch applied and saved to %s"), *DiskPath); + return ReloadThemeCache(OptionalAssetPath); +} diff --git a/Source/UmgMcp/Private/Widget/UmgGetSubsystem.cpp b/Source/UmgMcp/Private/Widget/UmgGetSubsystem.cpp index 41aeafe..2ceef0a 100644 --- a/Source/UmgMcp/Private/Widget/UmgGetSubsystem.cpp +++ b/Source/UmgMcp/Private/Widget/UmgGetSubsystem.cpp @@ -10,6 +10,7 @@ #include "Components/PanelSlot.h" #include "Components/CanvasPanelSlot.h" #include "FileManage/UmgFileTransformation.h" +#include "Widget/UmgMcpPropertyJsonUtils.h" #include "JsonObjectConverter.h" #include "Serialization/JsonWriter.h" #include "Dom/JsonObject.h" @@ -22,109 +23,6 @@ DEFINE_LOG_CATEGORY(LogUmgGet); -// --- Helper function for recursive JSON export --- -static TSharedPtr ExportWidgetToJson(UWidget* Widget) -{ - if (!Widget) - { - return nullptr; - } - - TSharedPtr WidgetJson = MakeShared(); - UObject* DefaultWidget = Widget->GetClass()->GetDefaultObject(); - - WidgetJson->SetStringField(TEXT("widget_name"), Widget->GetName()); - WidgetJson->SetStringField(TEXT("widget_class"), Widget->GetClass()->GetPathName()); - - TSharedPtr PropertiesJson = MakeShared(); - - for (TFieldIterator PropIt(Widget->GetClass()); PropIt; ++PropIt) - { - FProperty* Property = *PropIt; - bool bIsEditorOnly = false; -#if WITH_EDITOR - bIsEditorOnly = Property->HasAnyPropertyFlags(CPF_EditorOnly); -#endif - - if (Property->HasAnyPropertyFlags(CPF_Edit) && !Property->HasAnyPropertyFlags(CPF_Transient) && !bIsEditorOnly) - { - void* ValuePtr = Property->ContainerPtrToValuePtr(Widget); - void* DefaultValuePtr = Property->ContainerPtrToValuePtr(DefaultWidget); - - if (!Property->Identical(ValuePtr, DefaultValuePtr)) - { - if (Property->GetFName() == TEXT("Slot")) - { - if (UPanelSlot* SlotObject = Cast(CastField(Property)->GetObjectPropertyValue_InContainer(Widget))) - { - TSharedPtr SlotPropertiesJson = MakeShared(); - UObject* DefaultSlotObject = SlotObject->GetClass()->GetDefaultObject(); - - for (TFieldIterator SlotPropIt(SlotObject->GetClass()); SlotPropIt; ++SlotPropIt) - { - FProperty* SlotProperty = *SlotPropIt; - if ((SlotProperty->GetFName() != TEXT("Content")) && (SlotProperty->GetFName() != TEXT("Parent")) && SlotProperty->HasAnyPropertyFlags(CPF_Edit) && !SlotProperty->HasAnyPropertyFlags(CPF_Transient)) - { - void* SlotValuePtr = SlotProperty->ContainerPtrToValuePtr(SlotObject); - void* DefaultSlotValuePtr = SlotProperty->ContainerPtrToValuePtr(DefaultSlotObject); - if (!SlotProperty->Identical(SlotValuePtr, DefaultSlotValuePtr)) - { - TSharedPtr SlotPropertyJsonValue = FJsonObjectConverter::UPropertyToJsonValue(SlotProperty, SlotValuePtr); - if (SlotPropertyJsonValue.IsValid()) - { - SlotPropertiesJson->SetField(SlotProperty->GetName(), SlotPropertyJsonValue); - } - } - } - } - if(SlotPropertiesJson->Values.Num() > 0) - { - PropertiesJson->SetObjectField(TEXT("Slot"), SlotPropertiesJson); - } - } - } - else - { - TSharedPtr PropertyJsonValue = FJsonObjectConverter::UPropertyToJsonValue(Property, ValuePtr); - if (PropertyJsonValue.IsValid()) - { - PropertiesJson->SetField(Property->GetName(), PropertyJsonValue); - } - } - } - } - } - - if (PropertiesJson->Values.Num() > 0) - { - WidgetJson->SetObjectField(TEXT("properties"), PropertiesJson); - } - - TArray> ChildrenJsonArray; - if (UPanelWidget* PanelWidget = Cast(Widget)) - { - for (int32 i = 0; i < PanelWidget->GetChildrenCount(); ++i) - { - if (UWidget* ChildWidget = PanelWidget->GetChildAt(i)) - { - TSharedPtr ChildJson = ExportWidgetToJson(ChildWidget); - if (ChildJson.IsValid()) - { - ChildrenJsonArray.Add(MakeShared(ChildJson)); - } - } - } - } - - if (ChildrenJsonArray.Num() > 0) - { - WidgetJson->SetArrayField(TEXT("children"), ChildrenJsonArray); - } - - return WidgetJson; -} - - // --- Subsystem Implementation --- void UUmgGetSubsystem::Initialize(FSubsystemCollectionBase& Collection) @@ -226,6 +124,21 @@ FString UUmgGetSubsystem::QueryWidgetProperties(UWidgetBlueprint* WidgetBlueprin { TArray Parts; PropPath.ParseIntoArray(Parts, TEXT(".")); + + // Whole "Slot" must never go through FJsonObjectConverter on the UObject property + // (Widget.Slot -> Content -> Widget causes infinite recursion / stack overflow). + if (Parts.Num() == 1 && Parts[0].Equals(TEXT("Slot"), ESearchCase::IgnoreCase)) + { + if (TSharedPtr SlotJson = FUmgMcpPropertyJsonUtils::SerializePanelSlotProperties(FoundWidget->Slot)) + { + PropertiesJson->SetObjectField(PropPath, SlotJson); + } + else + { + PropertiesJson->SetObjectField(PropPath, MakeShared()); + } + continue; + } UObject* CurrentObject = FoundWidget; int32 PartIndex = 0; @@ -325,7 +238,7 @@ FString UUmgGetSubsystem::QueryWidgetProperties(UWidgetBlueprint* WidgetBlueprin if (PartIndex == Parts.Num() - 1) { // Found the target property - TSharedPtr PropertyJsonValue = FJsonObjectConverter::UPropertyToJsonValue(Property, CurrentValuePtr); + TSharedPtr PropertyJsonValue = FUmgMcpPropertyJsonUtils::PropertyToJsonValue(Property, CurrentValuePtr); if (PropertyJsonValue.IsValid()) { PropertiesJson->SetField(PropPath, PropertyJsonValue); diff --git a/Source/UmgMcp/Private/Widget/UmgMcpPropertyJsonUtils.cpp b/Source/UmgMcp/Private/Widget/UmgMcpPropertyJsonUtils.cpp new file mode 100644 index 0000000..208c004 --- /dev/null +++ b/Source/UmgMcp/Private/Widget/UmgMcpPropertyJsonUtils.cpp @@ -0,0 +1,144 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#include "Widget/UmgMcpPropertyJsonUtils.h" + +#include "Components/PanelSlot.h" +#include "Components/Widget.h" +#include "JsonObjectConverter.h" +#include "UObject/UnrealType.h" + +namespace UmgMcpPropertyJsonUtilsInternal +{ + static constexpr int32 MaxSerializeDepth = 32; + + static bool ShouldSkipSlotProperty(FName PropertyName) + { + return PropertyName == TEXT("Content") || PropertyName == TEXT("Parent"); + } + + static bool ShouldExportAsObjectPath(UObject* Object) + { + if (!Object) + { + return false; + } + + return Object->IsA() || Object->IsA(); + } +} + +TSharedPtr FUmgMcpPropertyJsonUtils::SerializePanelSlotProperties(UPanelSlot* SlotObject, bool bOnlyNonDefault) +{ + using namespace UmgMcpPropertyJsonUtilsInternal; + + if (!SlotObject) + { + return nullptr; + } + + TSharedPtr SlotPropertiesJson = MakeShared(); + UObject* DefaultSlotObject = SlotObject->GetClass()->GetDefaultObject(); + + for (TFieldIterator SlotPropIt(SlotObject->GetClass()); SlotPropIt; ++SlotPropIt) + { + FProperty* SlotProperty = *SlotPropIt; + if (ShouldSkipSlotProperty(SlotProperty->GetFName())) + { + continue; + } + + if (!SlotProperty->HasAnyPropertyFlags(CPF_Edit) || SlotProperty->HasAnyPropertyFlags(CPF_Transient)) + { + continue; + } + + void* SlotValuePtr = SlotProperty->ContainerPtrToValuePtr(SlotObject); + if (bOnlyNonDefault) + { + void* DefaultSlotValuePtr = SlotProperty->ContainerPtrToValuePtr(DefaultSlotObject); + if (SlotProperty->Identical(SlotValuePtr, DefaultSlotValuePtr)) + { + continue; + } + } + + if (TSharedPtr SlotPropertyJsonValue = PropertyToJsonValue(SlotProperty, SlotValuePtr)) + { + SlotPropertiesJson->SetField(SlotProperty->GetName(), SlotPropertyJsonValue); + } + } + + return SlotPropertiesJson->Values.Num() > 0 ? SlotPropertiesJson : nullptr; +} + +TSharedPtr FUmgMcpPropertyJsonUtils::PropertyToJsonValue(FProperty* Property, const void* ValuePtr, int32 Depth) +{ + using namespace UmgMcpPropertyJsonUtilsInternal; + + if (!Property || !ValuePtr) + { + return nullptr; + } + + if (Depth > MaxSerializeDepth) + { + return MakeShared(TEXT("")); + } + + if (FObjectProperty* ObjectProperty = CastField(Property)) + { + UObject* ObjectValue = ObjectProperty->GetObjectPropertyValue(ValuePtr); + if (!ObjectValue) + { + return MakeShared(); + } + + if (UPanelSlot* SlotObject = Cast(ObjectValue)) + { + if (TSharedPtr SlotJson = SerializePanelSlotProperties(SlotObject, /*bOnlyNonDefault*/ false)) + { + return MakeShared(SlotJson); + } + return MakeShared(MakeShared()); + } + + if (ShouldExportAsObjectPath(ObjectValue)) + { + return MakeShared(ObjectValue->GetPathName()); + } + + return MakeShared(ObjectValue->GetPathName()); + } + + if (FStructProperty* StructProperty = CastField(Property)) + { + TSharedPtr StructJson = MakeShared(); + for (TFieldIterator FieldIt(StructProperty->Struct); FieldIt; ++FieldIt) + { + FProperty* FieldProperty = *FieldIt; + const void* FieldValuePtr = FieldProperty->ContainerPtrToValuePtr(ValuePtr); + if (TSharedPtr FieldJson = PropertyToJsonValue(FieldProperty, FieldValuePtr, Depth + 1)) + { + StructJson->SetField(FieldProperty->GetName(), FieldJson); + } + } + return MakeShared(StructJson); + } + + return FJsonObjectConverter::UPropertyToJsonValue(Property, ValuePtr); +} + +bool FUmgMcpPropertyJsonUtils::TryWriteSlotPropertiesField(TSharedPtr& OutJson, const FString& FieldName, UWidget* Widget) +{ + if (!OutJson.IsValid() || !Widget || !Widget->Slot) + { + return false; + } + + if (TSharedPtr SlotJson = SerializePanelSlotProperties(Widget->Slot)) + { + OutJson->SetObjectField(FieldName, SlotJson); + return true; + } + + return false; +} diff --git a/Source/UmgMcp/Private/Widget/UmgMcpWidgetCommands.cpp b/Source/UmgMcp/Private/Widget/UmgMcpWidgetCommands.cpp index 8b2be1c..c006454 100644 --- a/Source/UmgMcp/Private/Widget/UmgMcpWidgetCommands.cpp +++ b/Source/UmgMcp/Private/Widget/UmgMcpWidgetCommands.cpp @@ -3,6 +3,7 @@ #include "Bridge/UmgMcpCommonUtils.h" #include "Widget/UmgGetSubsystem.h" #include "Widget/UmgSetSubsystem.h" +#include "Lint/UmgLintSubsystem.h" #include "FileManage/UmgAttentionSubsystem.h" #include "Editor.h" #include "Serialization/JsonSerializer.h" @@ -10,6 +11,76 @@ #include "WidgetBlueprint.h" #include "Misc/PackageName.h" +namespace UmgMcpWidgetCommandsInternal +{ + static bool MergeJsonIntoResponse(const FString& JsonString, TSharedPtr& OutResponse) + { + TSharedPtr Parsed; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonString); + if (!FJsonSerializer::Deserialize(Reader, Parsed) || !Parsed.IsValid()) + { + return false; + } + + for (const TPair>& Field : Parsed->Values) + { + OutResponse->SetField(Field.Key, Field.Value); + } + return true; + } + + static FUmgLintOptions ParseLintOptions(const TSharedPtr& Params) + { + FUmgLintOptions Options; + if (!Params.IsValid()) + { + return Options; + } + + double ViewportW = Options.ViewportWidth; + double ViewportH = Options.ViewportHeight; + double DepthThreshold = Options.DepthThreshold; + + if (Params->TryGetNumberField(TEXT("viewport_w"), ViewportW)) + { + Options.ViewportWidth = static_cast(ViewportW); + } + else if (Params->TryGetNumberField(TEXT("viewport_width"), ViewportW)) + { + Options.ViewportWidth = static_cast(ViewportW); + } + + if (Params->TryGetNumberField(TEXT("viewport_h"), ViewportH)) + { + Options.ViewportHeight = static_cast(ViewportH); + } + else if (Params->TryGetNumberField(TEXT("viewport_height"), ViewportH)) + { + Options.ViewportHeight = static_cast(ViewportH); + } + + if (Params->TryGetNumberField(TEXT("depth_threshold"), DepthThreshold)) + { + Options.DepthThreshold = static_cast(DepthThreshold); + } + + const TArray>* RulesArray = nullptr; + if (Params->TryGetArrayField(TEXT("rules"), RulesArray)) + { + for (const TSharedPtr& RuleValue : *RulesArray) + { + FString RuleName; + if (RuleValue->TryGetString(RuleName) && !RuleName.IsEmpty()) + { + Options.Rules.Add(RuleName); + } + } + } + + return Options; + } +} + TSharedPtr FUmgMcpWidgetCommands::HandleCommand(const FString& Command, const TSharedPtr& Params) @@ -75,7 +146,7 @@ TSharedPtr FUmgMcpWidgetCommands::HandleCommand(const FString& Comm Response->SetBoolField(TEXT("success"), true); for (const auto& Field : QueriedProperties->Values) { - Response->SetField(Field.Key, Field.Value); + Response->SetField(Field.Key.ToView(), Field.Value); } } else @@ -90,6 +161,102 @@ TSharedPtr FUmgMcpWidgetCommands::HandleCommand(const FString& Comm Response->SetStringField(TEXT("error"), TEXT("Missing 'widget_name' or 'properties' parameter.")); } } + else if (Command == TEXT("get_layout_data")) + { + UUmgLintSubsystem* LintSubsystem = GEditor->GetEditorSubsystem(); + int32 ViewportWidth = 1920; + int32 ViewportHeight = 1080; + + double WidthValue = ViewportWidth; + double HeightValue = ViewportHeight; + const TSharedPtr* ResolutionObject = nullptr; + if (Params->TryGetObjectField(TEXT("resolution"), ResolutionObject) && ResolutionObject && ResolutionObject->IsValid()) + { + (*ResolutionObject)->TryGetNumberField(TEXT("width"), WidthValue); + (*ResolutionObject)->TryGetNumberField(TEXT("height"), HeightValue); + } + else + { + Params->TryGetNumberField(TEXT("viewport_w"), WidthValue); + Params->TryGetNumberField(TEXT("viewport_h"), HeightValue); + Params->TryGetNumberField(TEXT("viewport_width"), WidthValue); + Params->TryGetNumberField(TEXT("viewport_height"), HeightValue); + } + + ViewportWidth = static_cast(WidthValue); + ViewportHeight = static_cast(HeightValue); + + const FString LayoutJson = LintSubsystem->GetLayoutDataFromPreview(TargetBlueprint, ViewportWidth, ViewportHeight); + if (UmgMcpWidgetCommandsInternal::MergeJsonIntoResponse(LayoutJson, Response)) + { + if (!Response->HasField(TEXT("success"))) + { + Response->SetBoolField(TEXT("success"), true); + } + } + else + { + Response->SetBoolField(TEXT("success"), false); + Response->SetStringField(TEXT("error"), TEXT("Failed to parse layout data response.")); + } + } + else if (Command == TEXT("lint_umg_asset")) + { + UUmgLintSubsystem* LintSubsystem = GEditor->GetEditorSubsystem(); + const FUmgLintOptions Options = UmgMcpWidgetCommandsInternal::ParseLintOptions(Params); + const FString LintJson = LintSubsystem->AnalyzeAsset(TargetBlueprint, Options); + if (UmgMcpWidgetCommandsInternal::MergeJsonIntoResponse(LintJson, Response)) + { + if (!Response->HasField(TEXT("success"))) + { + Response->SetBoolField(TEXT("success"), true); + } + } + else + { + Response->SetBoolField(TEXT("success"), false); + Response->SetStringField(TEXT("error"), TEXT("Failed to parse lint report.")); + } + } + else if (Command == TEXT("check_widget_overlap")) + { + UUmgLintSubsystem* LintSubsystem = GEditor->GetEditorSubsystem(); + FUmgLintOptions Options = UmgMcpWidgetCommandsInternal::ParseLintOptions(Params); + Options.Rules = { TEXT("layout-overlap") }; + + const FString LintJson = LintSubsystem->AnalyzeAsset(TargetBlueprint, Options); + if (UmgMcpWidgetCommandsInternal::MergeJsonIntoResponse(LintJson, Response)) + { + bool bHasOverlap = false; + const TArray>* IssuesArray = nullptr; + if (Response->TryGetArrayField(TEXT("issues"), IssuesArray)) + { + for (const TSharedPtr& IssueValue : *IssuesArray) + { + const TSharedPtr* IssueObject = nullptr; + if (IssueValue->TryGetObject(IssueObject) && IssueObject && IssueObject->IsValid()) + { + FString RuleId; + if ((*IssueObject)->TryGetStringField(TEXT("ruleId"), RuleId) && RuleId == TEXT("layout-overlap")) + { + bHasOverlap = true; + break; + } + } + } + } + Response->SetBoolField(TEXT("has_overlap"), bHasOverlap); + if (!Response->HasField(TEXT("success"))) + { + Response->SetBoolField(TEXT("success"), true); + } + } + else + { + Response->SetBoolField(TEXT("success"), false); + Response->SetStringField(TEXT("error"), TEXT("Failed to parse overlap check response.")); + } + } else if (Command == TEXT("get_widget_schema")) { UUmgGetSubsystem* GetSubsystem = GEditor->GetEditorSubsystem(); @@ -104,7 +271,7 @@ TSharedPtr FUmgMcpWidgetCommands::HandleCommand(const FString& Comm Response->SetBoolField(TEXT("success"), true); for (const auto& Field : SchemaJson->Values) { - Response->SetField(Field.Key, Field.Value); + Response->SetField(Field.Key.ToView(), Field.Value); } } else @@ -229,6 +396,7 @@ TSharedPtr FUmgMcpWidgetCommands::HandleCommand(const FString& Comm if (SetSubsystem->DeleteWidget(TargetBlueprint, WidgetName)) { Response->SetBoolField(TEXT("success"), true); + Response->SetStringField(TEXT("eliminated_widget"), WidgetName); Response->SetStringField(TEXT("deleted_widget"), WidgetName); } else @@ -367,4 +535,3 @@ TSharedPtr FUmgMcpWidgetCommands::HandleCommand(const FString& Comm } - diff --git a/Source/UmgMcp/Private/Widget/UmgSetSubsystem.cpp b/Source/UmgMcp/Private/Widget/UmgSetSubsystem.cpp index c339eed..a5bf247 100644 --- a/Source/UmgMcp/Private/Widget/UmgSetSubsystem.cpp +++ b/Source/UmgMcp/Private/Widget/UmgSetSubsystem.cpp @@ -2,6 +2,7 @@ #include "Widget/UmgSetSubsystem.h" #include "FileManage/UmgAttentionSubsystem.h" +#include "Theme/UmgThemeSubsystem.h" #include "Editor.h" #include "WidgetBlueprint.h" #include "Blueprint/WidgetTree.h" @@ -17,6 +18,144 @@ DEFINE_LOG_CATEGORY(LogUmgSet); +namespace UmgSetInternal +{ + static bool ValidateSlotJsonKeys(UStruct* SlotClass, const TSharedPtr& SlotJson, FString& OutInvalidKey) + { + if (!SlotClass || !SlotJson.IsValid()) + { + return true; + } + + for (const TPair>& Field : SlotJson->Values) + { + if (!SlotClass->FindPropertyByName(FName(*Field.Key))) + { + OutInvalidKey = Field.Key; + return false; + } + } + return true; + } + + static FString NormalizeSizeRuleString(const FString& InRule) + { + if (InRule.Equals(TEXT("Auto"), ESearchCase::IgnoreCase) + || InRule.Equals(TEXT("Automatic"), ESearchCase::IgnoreCase)) + { + return TEXT("Automatic"); + } + if (InRule.Equals(TEXT("Fill"), ESearchCase::IgnoreCase)) + { + return TEXT("Fill"); + } + return InRule; + } + + static FString NormalizeHorizontalAlignmentString(const FString& InAlign) + { + if (InAlign.StartsWith(TEXT("HAlign_"), ESearchCase::IgnoreCase)) + { + return InAlign; + } + + static const TMap AliasMap = { + {TEXT("Left"), TEXT("HAlign_Left")}, + {TEXT("Center"), TEXT("HAlign_Center")}, + {TEXT("Right"), TEXT("HAlign_Right")}, + {TEXT("Fill"), TEXT("HAlign_Fill")}, + }; + + if (const FString* Mapped = AliasMap.Find(InAlign)) + { + return *Mapped; + } + + return InAlign; + } + + static FString NormalizeVerticalAlignmentString(const FString& InAlign) + { + if (InAlign.StartsWith(TEXT("VAlign_"), ESearchCase::IgnoreCase)) + { + return InAlign; + } + + static const TMap AliasMap = { + {TEXT("Top"), TEXT("VAlign_Top")}, + {TEXT("Center"), TEXT("VAlign_Center")}, + {TEXT("Bottom"), TEXT("VAlign_Bottom")}, + {TEXT("Fill"), TEXT("VAlign_Fill")}, + }; + + if (const FString* Mapped = AliasMap.Find(InAlign)) + { + return *Mapped; + } + + return InAlign; + } + + static void NormalizeSlotJsonRecursively(const TSharedPtr& SlotJson) + { + if (!SlotJson.IsValid()) + { + return; + } + + TArray FieldKeys; + FieldKeys.Reserve(SlotJson->Values.Num()); + for (const TPair>& Pair : SlotJson->Values) + { + FieldKeys.Add(Pair.Key); + } + + for (const FString& FieldKey : FieldKeys) + { + TSharedPtr FieldValue = SlotJson->TryGetField(FieldKey); + if (!FieldValue.IsValid()) + { + continue; + } + + if (FieldKey.Equals(TEXT("Size"), ESearchCase::IgnoreCase) && FieldValue->Type == EJson::Object) + { + TSharedPtr SizeObject = FieldValue->AsObject(); + if (SizeObject.IsValid()) + { + FString SizeRule; + if (SizeObject->TryGetStringField(TEXT("SizeRule"), SizeRule)) + { + SizeObject->SetStringField(TEXT("SizeRule"), NormalizeSizeRuleString(SizeRule)); + } + NormalizeSlotJsonRecursively(SizeObject); + SlotJson->SetObjectField(FieldKey, SizeObject); + } + continue; + } + + if (FieldKey.Equals(TEXT("HorizontalAlignment"), ESearchCase::IgnoreCase) && FieldValue->Type == EJson::String) + { + SlotJson->SetStringField(FieldKey, NormalizeHorizontalAlignmentString(FieldValue->AsString())); + continue; + } + + if (FieldKey.Equals(TEXT("VerticalAlignment"), ESearchCase::IgnoreCase) && FieldValue->Type == EJson::String) + { + SlotJson->SetStringField(FieldKey, NormalizeVerticalAlignmentString(FieldValue->AsString())); + continue; + } + + if (FieldValue->Type == EJson::Object) + { + TSharedPtr ChildObject = FieldValue->AsObject(); + NormalizeSlotJsonRecursively(ChildObject); + SlotJson->SetObjectField(FieldKey, ChildObject); + } + } + } +} + void UUmgSetSubsystem::Initialize(FSubsystemCollectionBase& Collection) { Super::Initialize(Collection); @@ -60,13 +199,29 @@ bool UUmgSetSubsystem::SetWidgetProperties(UWidgetBlueprint* WidgetBlueprint, co return false; } + if (GEditor) + { + if (UUmgThemeSubsystem* ThemeSubsystem = GEditor->GetEditorSubsystem()) + { + UE_LOG(LogUmgSet, Verbose, TEXT("SetWidgetProperties: resolving theme tokens for widget '%s' (%d top-level keys)"), *WidgetName, PropertiesJsonObject->Values.Num()); + ThemeSubsystem->ProcessJsonTokens(PropertiesJsonObject); + } + else + { + UE_LOG(LogUmgSet, Warning, TEXT("SetWidgetProperties: UmgThemeSubsystem unavailable; skipping @token resolution for widget '%s'."), *WidgetName); + } + } + // Normalize JSON keys from camelCase to PascalCase (especially important for Slot properties) UE_LOG(LogUmgSet, Log, TEXT("SetWidgetProperties: Normalizing property keys for widget '%s'"), *WidgetName); TSharedPtr NormalizedProperties = UUmgFileTransformation::NormalizeJsonKeysToPascalCase(PropertiesJsonObject); - + // 2. Extract and expand aliases in NormalizedProperties TArray CurrentKeys; - NormalizedProperties->Values.GetKeys(CurrentKeys); + for (const auto& Pair : NormalizedProperties->Values) + { + CurrentKeys.Add(FString(Pair.Key.ToView())); + } for (const FString& Key : CurrentKeys) { if (Key.StartsWith(TEXT("Slot."), ESearchCase::IgnoreCase)) @@ -95,8 +250,8 @@ bool UUmgSetSubsystem::SetWidgetProperties(UWidgetBlueprint* WidgetBlueprint, co // [智能反射匹配失败]:安全降级,进入 Canvas 别名处理层判断 if (Key.Equals(TEXT("Slot.Position"), ESearchCase::IgnoreCase)) { - TSharedPtr Val = NormalizedProperties->Values[Key]; - if (Val->Type == EJson::Array && Val->AsArray().Num() >= 2) { + TSharedPtr Val = NormalizedProperties->TryGetField(Key); + if (Val.IsValid() && Val->Type == EJson::Array && Val->AsArray().Num() >= 2) { NormalizedProperties->SetField(TEXT("Slot.LayoutData.Offsets.Left"), Val->AsArray()[0]); NormalizedProperties->SetField(TEXT("Slot.LayoutData.Offsets.Top"), Val->AsArray()[1]); NormalizedProperties->RemoveField(Key); @@ -104,8 +259,8 @@ bool UUmgSetSubsystem::SetWidgetProperties(UWidgetBlueprint* WidgetBlueprint, co } else if (Key.Equals(TEXT("Slot.Size"), ESearchCase::IgnoreCase)) { - TSharedPtr Val = NormalizedProperties->Values[Key]; - if (Val->Type == EJson::Array && Val->AsArray().Num() >= 2) { + TSharedPtr Val = NormalizedProperties->TryGetField(Key); + if (Val.IsValid() && Val->Type == EJson::Array && Val->AsArray().Num() >= 2) { NormalizedProperties->SetField(TEXT("Slot.LayoutData.Offsets.Right"), Val->AsArray()[0]); NormalizedProperties->SetField(TEXT("Slot.LayoutData.Offsets.Bottom"), Val->AsArray()[1]); NormalizedProperties->RemoveField(Key); @@ -113,12 +268,12 @@ bool UUmgSetSubsystem::SetWidgetProperties(UWidgetBlueprint* WidgetBlueprint, co } else if (Key.Equals(TEXT("Slot.Anchors"), ESearchCase::IgnoreCase)) { - NormalizedProperties->SetField(TEXT("Slot.LayoutData.Anchors"), NormalizedProperties->Values[Key]); + NormalizedProperties->SetField(TEXT("Slot.LayoutData.Anchors"), NormalizedProperties->TryGetField(Key)); NormalizedProperties->RemoveField(Key); } else if (Key.Equals(TEXT("Slot.Alignment"), ESearchCase::IgnoreCase)) { - NormalizedProperties->SetField(TEXT("Slot.LayoutData.Alignment"), NormalizedProperties->Values[Key]); + NormalizedProperties->SetField(TEXT("Slot.LayoutData.Alignment"), NormalizedProperties->TryGetField(Key)); NormalizedProperties->RemoveField(Key); } } @@ -133,7 +288,11 @@ bool UUmgSetSubsystem::SetWidgetProperties(UWidgetBlueprint* WidgetBlueprint, co } // Re-scan for any dotted keys (including expanded aliases) - NormalizedProperties->Values.GetKeys(CurrentKeys); + CurrentKeys.Reset(); + for (const auto& Pair : NormalizedProperties->Values) + { + CurrentKeys.Add(FString(Pair.Key.ToView())); + } for (const FString& FullKey : CurrentKeys) { if (FullKey.Contains(TEXT("."))) @@ -163,7 +322,7 @@ bool UUmgSetSubsystem::SetWidgetProperties(UWidgetBlueprint* WidgetBlueprint, co TargetObj = ConstCastSharedPtr(*ExistingObj); } } - TargetObj->SetField(Parts.Last(), NormalizedProperties->Values[FullKey]); + TargetObj->SetField(Parts.Last(), NormalizedProperties->TryGetField(FullKey)); NormalizedProperties->RemoveField(FullKey); } } @@ -212,7 +371,8 @@ bool UUmgSetSubsystem::SetWidgetProperties(UWidgetBlueprint* WidgetBlueprint, co { for (const auto& Pair : NormalizedProperties->Values) { - FProperty* Prop = FoundWidget->GetClass()->FindPropertyByName(FName(*Pair.Key)); + const FString Key(Pair.Key.ToView()); + FProperty* Prop = FoundWidget->GetClass()->FindPropertyByName(FName(*Key)); if (!Prop) continue; // SPECIAL CASE: Auto-resolve Object Pointers from paths @@ -232,19 +392,48 @@ bool UUmgSetSubsystem::SetWidgetProperties(UWidgetBlueprint* WidgetBlueprint, co // Fallback: Use standard converter for this specific field TSharedPtr SinglePropJson = MakeShared(); - SinglePropJson->SetField(Pair.Key, Pair.Value); + SinglePropJson->SetField(Key, Pair.Value); FJsonObjectConverter::JsonObjectToUStruct(SinglePropJson.ToSharedRef(), FoundWidget->GetClass(), FoundWidget, 0, 0); } } - + // Apply Slot properties separately (CRITICAL: must apply to Slot object, not Widget object) - if (SlotProperties.IsValid() && FoundWidget->Slot) + if (SlotProperties.IsValid() && SlotProperties->Values.Num() > 0) { + if (!FoundWidget->Slot) + { + UE_LOG(LogUmgSet, Error, TEXT("SetWidgetProperties: Slot properties provided for '%s' but widget has no Slot."), *WidgetName); + return false; + } + + FString InvalidSlotKey; + if (!UmgSetInternal::ValidateSlotJsonKeys(FoundWidget->Slot->GetClass(), SlotProperties, InvalidSlotKey)) + { + UE_LOG(LogUmgSet, Error, TEXT("SetWidgetProperties: Slot property '%s' is invalid for slot class '%s' on widget '%s'."), + *InvalidSlotKey, *FoundWidget->Slot->GetClass()->GetName(), *WidgetName); + return false; + } + + UmgSetInternal::NormalizeSlotJsonRecursively(SlotProperties); + UE_LOG(LogUmgSet, Log, TEXT("SetWidgetProperties: Applying Slot properties to Slot object (class: %s)"), *FoundWidget->Slot->GetClass()->GetName()); FoundWidget->Slot->Modify(); - // Standard converter is usually fine for Slots (mostly numeric/enums) - FJsonObjectConverter::JsonObjectToUStruct(SlotProperties.ToSharedRef(), FoundWidget->Slot->GetClass(), FoundWidget->Slot, 0, 0); + const bool bSlotApplied = FJsonObjectConverter::JsonObjectToUStruct( + SlotProperties.ToSharedRef(), + FoundWidget->Slot->GetClass(), + FoundWidget->Slot, + 0, + 0); + if (!bSlotApplied) + { + FString NormalizedSlotJsonString; + TSharedRef> NormalizedWriter = TJsonWriterFactory<>::Create(&NormalizedSlotJsonString); + FJsonSerializer::Serialize(SlotProperties.ToSharedRef(), NormalizedWriter); + UE_LOG(LogUmgSet, Error, TEXT("SetWidgetProperties: Failed to apply Slot properties to widget '%s'. Normalized slot JSON: %s"), + *WidgetName, *NormalizedSlotJsonString); + return false; + } } FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(WidgetBlueprint); @@ -465,10 +654,12 @@ bool UUmgSetSubsystem::DeleteWidget(UWidgetBlueprint* WidgetBlueprint, const FSt if (WidgetBlueprint->WidgetTree->RemoveWidget(FoundWidget)) { + UE_LOG(LogUmgSet, Log, TEXT("DeleteWidget: Eliminated widget '%s' from asset '%s'."), *WidgetName, *WidgetBlueprint->GetPathName()); FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(WidgetBlueprint); return true; } + UE_LOG(LogUmgSet, Warning, TEXT("DeleteWidget: Elimination of widget '%s' failed."), *WidgetName); return false; } @@ -552,7 +743,7 @@ TArray UUmgSetSubsystem::ReparentWidget(UWidgetBlueprint* WidgetBluepri WidgetClass = FindObject(nullptr, *WidgetClassPath); if (!WidgetClass) WidgetClass = LoadObject(nullptr, *WidgetClassPath); } - + if (!WidgetClass) { FString NativePath = FString::Printf(TEXT("/Script/UMG.%s"), *WidgetClassPath); @@ -637,10 +828,10 @@ TArray UUmgSetSubsystem::ReparentWidget(UWidgetBlueprint* WidgetBluepri { OldParent->Modify(); int32 ChildIndex = OldParent->GetChildIndex(WidgetToReplace); - + // Temporarily detach old widget OldParent->RemoveChild(WidgetToReplace); - + // Insert new widget at the same index UPanelSlot* NewSlot = nullptr; if (ChildIndex != INDEX_NONE) @@ -681,7 +872,7 @@ TArray UUmgSetSubsystem::ReparentWidget(UWidgetBlueprint* WidgetBluepri { OldPanel->Modify(); NewPanel->Modify(); - + TArray ChildrenToMove; for (int32 i = 0; i < OldPanel->GetChildrenCount(); ++i) { @@ -690,7 +881,7 @@ TArray UUmgSetSubsystem::ReparentWidget(UWidgetBlueprint* WidgetBluepri ChildrenToMove.Add(Child); } } - + OldPanel->ClearChildren(); for (UWidget* Child : ChildrenToMove) { diff --git a/Source/UmgMcp/Public/Bridge/UmgMcpBridge.h b/Source/UmgMcp/Public/Bridge/UmgMcpBridge.h index 75d3f29..5a4a46e 100644 --- a/Source/UmgMcp/Public/Bridge/UmgMcpBridge.h +++ b/Source/UmgMcp/Public/Bridge/UmgMcpBridge.h @@ -16,6 +16,8 @@ #include "Widget/UmgMcpWidgetCommands.h" #include "FileManage/UmgMcpFileTransformationCommands.h" #include "Animation/UmgMcpSequencerCommands.h" // Add Sequencer Commands +#include "Storybook/UmgMcpStorybookCommands.h" +#include "Orchestration/UmgMcpOrchestrationCommands.h" #include "UmgMcpBridge.generated.h" @@ -25,6 +27,8 @@ class FUmgMcpWidgetCommands; // Forward declaration for Widget Commands class FUmgMcpFileTransformationCommands; // Forward declaration for File Transformation Commands class FUmgMcpSequencerCommands; // Forward declaration for Sequencer Commands class FUmgMcpMaterialCommands; // Forward declaration for Material Commands +class FUmgMcpStorybookCommands; +class FUmgMcpOrchestrationCommands; /** * @brief The central communication hub for the UMG MCP plugin. @@ -66,6 +70,8 @@ class UMGMCP_API UUmgMcpBridge : public UEditorSubsystem TSharedPtr FileTransformationCommands; TSharedPtr SequencerCommands; // Add Sequencer Commands TSharedPtr MaterialCommands; + TSharedPtr StorybookCommands; + TSharedPtr OrchestrationCommands; // Server state variables bool bIsRunning; diff --git a/Source/UmgMcp/Public/Catalog/UmgCatalogSubsystem.h b/Source/UmgMcp/Public/Catalog/UmgCatalogSubsystem.h new file mode 100644 index 0000000..63ef182 --- /dev/null +++ b/Source/UmgMcp/Public/Catalog/UmgCatalogSubsystem.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#pragma once + +#include "CoreMinimal.h" +#include "EditorSubsystem.h" +#include "UmgCatalogSubsystem.generated.h" + +DECLARE_LOG_CATEGORY_EXTERN(LogUmgCatalog, Log, All); + +/** + * Semantic component catalog above raw list_assets. + * Scans WidgetBlueprint assets with tags and exposes spawn parameters. + */ +UCLASS() +class UMGMCP_API UUmgCatalogSubsystem : public UEditorSubsystem +{ + GENERATED_BODY() + +public: + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + + /** List reusable WBP components under a content root. */ + FString ListComponents(const FString& RootPath, bool bRecursive = true) const; + + /** Describe slots and Expose-on-Spawn params for a WBP asset path. */ + FString DescribeComponent(const FString& AssetPath) const; + +private: + FString ResolveWidgetBlueprintPath(const FString& AssetPath) const; +}; diff --git a/Source/UmgMcp/Public/FileManage/UmgAttentionSubsystem.h b/Source/UmgMcp/Public/FileManage/UmgAttentionSubsystem.h index 83ee400..24014cd 100644 --- a/Source/UmgMcp/Public/FileManage/UmgAttentionSubsystem.h +++ b/Source/UmgMcp/Public/FileManage/UmgAttentionSubsystem.h @@ -123,6 +123,13 @@ class UMGMCP_API UUmgAttentionSubsystem : public UEditorSubsystem UFUNCTION(BlueprintCallable, Category = "UMG MCP|Attention") void SetCursorPosition(const FVector2D& NewPosition); + /** Monotonic revision counter bumped after successful patch_apply. */ + UFUNCTION(BlueprintCallable, Category = "UMG MCP|Attention") + int32 GetPatchRevision() const { return PatchRevisionCounter; } + + UFUNCTION(BlueprintCallable, Category = "UMG MCP|Attention") + int32 IncrementPatchRevision(); + private: void HandleAssetOpened(UObject* Asset, class IAssetEditorInstance* EditorInstance); @@ -143,6 +150,8 @@ class UMGMCP_API UUmgAttentionSubsystem : public UEditorSubsystem FString LastEditedNodeId; // The "Program Counter" FVector2D CurrentNodePosition; // The visual cursor + int32 PatchRevisionCounter = 0; + protected: TArray UmgAssetHistory; }; \ No newline at end of file diff --git a/Source/UmgMcp/Public/FileManage/UmgFileTransformation.h b/Source/UmgMcp/Public/FileManage/UmgFileTransformation.h index d911e45..fcf8f1c 100644 --- a/Source/UmgMcp/Public/FileManage/UmgFileTransformation.h +++ b/Source/UmgMcp/Public/FileManage/UmgFileTransformation.h @@ -10,6 +10,15 @@ class UWidget; class FJsonObject; +/** Result of applying JSON layout to a UMG asset (synchronous, GameThread-confirmed). */ +struct FApplyJsonToUmgResult +{ + bool bSuccess = false; + bool bApplied = false; + FString ErrorMessage; + TArray ReparentedWidgets; +}; + /** * @brief Handles the "compilation" and "decompilation" of UMG assets to and from JSON. * @@ -49,7 +58,8 @@ class UMGMCP_API UUmgFileTransformation : public UObject * @param TargetWidgetName Optional. The name of the specific widget to replace. If empty or "Root", the entire widget tree (RootWidget) is replaced. * @return True if the operation was successful, false otherwise. */ - static bool ApplyJsonStringToUmgAsset(const FString& AssetPath, const FString& JsonData, const FString& TargetWidgetName = TEXT("")); + /** Synchronous apply — blocks until GameThread apply completes and returns confirmed result. */ + static FApplyJsonToUmgResult ApplyJsonStringToUmgAsset(const FString& AssetPath, const FString& JsonData, const FString& TargetWidgetName = TEXT("")); /** * Normalizes JSON keys from camelCase to PascalCase to match C++ UPROPERTY names. diff --git a/Source/UmgMcp/Public/Lint/UmgLintSubsystem.h b/Source/UmgMcp/Public/Lint/UmgLintSubsystem.h new file mode 100644 index 0000000..82b7e7e --- /dev/null +++ b/Source/UmgMcp/Public/Lint/UmgLintSubsystem.h @@ -0,0 +1,107 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#pragma once + +#include "CoreMinimal.h" +#include "EditorSubsystem.h" +#include "UmgLintSubsystem.generated.h" + +class UWidgetBlueprint; +class UUserWidget; +class UWidget; +class UPanelWidget; + +DECLARE_LOG_CATEGORY_EXTERN(LogUmgLint, Log, All); + +USTRUCT() +struct FUmgLintOptions +{ + GENERATED_BODY() + + UPROPERTY() + TArray Rules; + + UPROPERTY() + int32 ViewportWidth = 1920; + + UPROPERTY() + int32 ViewportHeight = 1080; + + UPROPERTY() + int32 DepthThreshold = 10; +}; + +USTRUCT() +struct FUmgLintIssue +{ + GENERATED_BODY() + + UPROPERTY() + FString RuleId; + + UPROPERTY() + FString Severity; + + UPROPERTY() + FString Message; + + UPROPERTY() + FString WidgetPath; + + UPROPERTY() + TArray RelatedWidgets; + + UPROPERTY() + FString Source = TEXT("asset-time"); + + TSharedPtr FixSuggestion; +}; + +/** + * ESLint-style UMG asset linting for AI-driven UI iteration. + */ +UCLASS() +class UMGMCP_API UUmgLintSubsystem : public UEditorSubsystem +{ + GENERATED_BODY() + +public: + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + + /** Run lint rules and return a JSON report string. */ + FString AnalyzeAsset(UWidgetBlueprint* WidgetBlueprint, const FUmgLintOptions& Options); + + /** Collect layout bounding boxes after building a preview Slate tree. */ + FString GetLayoutDataFromPreview(UWidgetBlueprint* WidgetBlueprint, int32 ViewportWidth, int32 ViewportHeight); + +private: + bool ShouldRunRule(const FUmgLintOptions& Options, const FString& RuleId) const; + bool LoadRulesConfig(int32& OutDepthThreshold, TArray>& OutNamingRules) const; + FString GetRulesConfigPath() const; + + UUserWidget* CreatePreviewInstance(UWidgetBlueprint* WidgetBlueprint, FString& OutError) const; + void DestroyPreviewInstance(UUserWidget* PreviewWidget) const; + bool ArrangePreviewGeometry(UUserWidget* PreviewWidget, int32 ViewportWidth, int32 ViewportHeight) const; + + static TSharedPtr BuildOverlapFixSuggestion( + UWidget* MoveWidget, + UPanelWidget* ParentPanel, + float OverlapHeight, + float OverlapWidth); + + static bool IsRectValidForOverlap(const FSlateRect& Rect); + + static FString BuildWidgetPath(UWidget* Widget); + static int32 GetWidgetDepth(UWidget* Widget); + static bool IsWidgetVisibleForLint(UWidget* Widget); + static bool IsWidgetHitTestBlocking(UWidget* Widget); + static bool ShouldSkipOverlapCheck(UWidget* Widget); + + void RunNamingConventionRule(UWidgetBlueprint* WidgetBlueprint, const TArray>& NamingRules, TArray& OutIssues); + void RunNestingDepthRule(UWidgetBlueprint* WidgetBlueprint, int32 DepthThreshold, TArray& OutIssues); + void RunLayoutOverlapRule(UUserWidget* PreviewWidget, int32 ViewportWidth, int32 ViewportHeight, TArray& OutIssues, bool& bPreviewReady); + + static TSharedPtr IssueToJson(const FUmgLintIssue& Issue); + static TSharedPtr BuildReport(UWidgetBlueprint* WidgetBlueprint, const TArray& Issues, bool bGeometryTrusted = true); + static FString SerializeJsonObject(const TSharedPtr& JsonObject); +}; diff --git a/Source/UmgMcp/Public/Orchestration/UmgMcpOrchestrationCommands.h b/Source/UmgMcp/Public/Orchestration/UmgMcpOrchestrationCommands.h new file mode 100644 index 0000000..0c4ee26 --- /dev/null +++ b/Source/UmgMcp/Public/Orchestration/UmgMcpOrchestrationCommands.h @@ -0,0 +1,12 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#pragma once + +#include "CoreMinimal.h" +#include "Dom/JsonObject.h" + +/** Routes theme, catalog, and patch orchestration commands. */ +class FUmgMcpOrchestrationCommands +{ +public: + TSharedPtr HandleCommand(const FString& CommandType, const TSharedPtr& Params); +}; diff --git a/Source/UmgMcp/Public/Patch/UmgPatchSubsystem.h b/Source/UmgMcp/Public/Patch/UmgPatchSubsystem.h new file mode 100644 index 0000000..b5be7a2 --- /dev/null +++ b/Source/UmgMcp/Public/Patch/UmgPatchSubsystem.h @@ -0,0 +1,42 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#pragma once + +#include "CoreMinimal.h" +#include "EditorSubsystem.h" +#include "UmgPatchSubsystem.generated.h" + +class UWidgetBlueprint; + +DECLARE_LOG_CATEGORY_EXTERN(LogUmgPatch, Log, All); + +/** + * Orchestration layer: apply RFC6902-style patch ops inside one undo transaction. + */ +UCLASS() +class UMGMCP_API UUmgPatchSubsystem : public UEditorSubsystem +{ + GENERATED_BODY() + +public: + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + + /** Apply patch array atomically; increments attention revision on success. */ + FString ApplyPatch( + UWidgetBlueprint* WidgetBlueprint, + const TArray>& PatchOps, + TOptional ExpectedRevision = TOptional()); + +private: + struct FPatchPathInfo + { + FString WidgetName; + FString PropertyPath; + FString ParentName; + bool bIsChildAppend = false; + }; + + bool ParsePatchPath(const FString& Path, const FString& OpType, FPatchPathInfo& OutInfo, FString& OutError) const; + bool ApplySetOp(UWidgetBlueprint* WidgetBlueprint, const FPatchPathInfo& PathInfo, const TSharedPtr& Value, FString& OutError) const; + bool ApplyAddOp(UWidgetBlueprint* WidgetBlueprint, const FPatchPathInfo& PathInfo, const TSharedPtr& Value, FString& OutError) const; + bool ApplyRemoveOp(UWidgetBlueprint* WidgetBlueprint, const FPatchPathInfo& PathInfo, FString& OutError) const; +}; diff --git a/Source/UmgMcp/Public/Preview/UmgPreviewRenderUtils.h b/Source/UmgMcp/Public/Preview/UmgPreviewRenderUtils.h new file mode 100644 index 0000000..7c1056e --- /dev/null +++ b/Source/UmgMcp/Public/Preview/UmgPreviewRenderUtils.h @@ -0,0 +1,35 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#pragma once + +#include "CoreMinimal.h" + +class SVirtualWindow; +class UUserWidget; +class UWidgetBlueprint; +class UWorld; + +/** + * Hosts a Slate tree inside SVirtualWindow so off-screen FWidgetRenderer gets valid geometry + * without opening the UMG Designer or attaching to a viewport. + */ +class UMGMCP_API FUmgPreviewRenderUtils +{ +public: + /** Recompile so GeneratedClass matches the latest WidgetTree (required after MCP layout edits). */ + static void CompileWidgetBlueprint(UWidgetBlueprint* WidgetBlueprint); + + /** + * Create a transient preview instance and build Slate for UserWidget + all tree widgets. + * Caller must destroy the returned widget with ConditionalBeginDestroy(). + */ + static UUserWidget* CreatePreviewInstance(UWidgetBlueprint* WidgetBlueprint, UWorld* World, FString& OutError); + + /** Wrap content in a virtual window, run SlatePrepass, optionally register with SlateApplication. */ + static TSharedRef PrepareOffscreenRenderTree( + TSharedRef Content, + const FVector2D& DrawSize, + TSharedPtr& OutVirtualWindow); + + /** Unregister and release a virtual window created by PrepareOffscreenRenderTree. */ + static void ReleaseOffscreenRenderHost(TSharedPtr& VirtualWindow); +}; diff --git a/Source/UmgMcp/Public/Storybook/UmgMcpStorybookCommands.h b/Source/UmgMcp/Public/Storybook/UmgMcpStorybookCommands.h new file mode 100644 index 0000000..062f768 --- /dev/null +++ b/Source/UmgMcp/Public/Storybook/UmgMcpStorybookCommands.h @@ -0,0 +1,17 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#pragma once + +#include "CoreMinimal.h" +#include "Dom/JsonObject.h" + +/** + * MCP command handler for UMG Storybook preview/screenshot tools. + */ +class UMGMCP_API FUmgMcpStorybookCommands +{ +public: + TSharedPtr HandleCommand(const FString& CommandType, const TSharedPtr& Params); + +private: + static int32 ResolveViewportDimension(const TSharedPtr& Params, const TCHAR* PrimaryKey, const TCHAR* AlternateKey, int32 DefaultValue); +}; diff --git a/Source/UmgMcp/Public/Storybook/UmgStorybookSubsystem.h b/Source/UmgMcp/Public/Storybook/UmgStorybookSubsystem.h new file mode 100644 index 0000000..5dd2440 --- /dev/null +++ b/Source/UmgMcp/Public/Storybook/UmgStorybookSubsystem.h @@ -0,0 +1,52 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#pragma once + +#include "CoreMinimal.h" +#include "EditorSubsystem.h" +#include "UmgStorybookSubsystem.generated.h" + +class UWidgetBlueprint; +class UTextureRenderTarget2D; + +DECLARE_LOG_CATEGORY_EXTERN(LogUmgStorybook, Log, All); + +/** + * Isolated UMG widget preview rendering for AI visual feedback (Storybook). + * Renders a widget subtree off-screen via FWidgetRenderer and returns PNG/base64. + */ +UCLASS() +class UMGMCP_API UUmgStorybookSubsystem : public UEditorSubsystem +{ + GENERATED_BODY() + +public: + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + + /** Render preview; returns JSON with image_base64, width, height, widget_path. */ + FString RenderWidgetPreview( + UWidgetBlueprint* WidgetBlueprint, + const FString& WidgetName, + int32 ViewportWidth, + int32 ViewportHeight, + const FString& Theme); + + /** List child widget names suitable as storybook variants. */ + FString ListVariants( + UWidgetBlueprint* WidgetBlueprint, + const FString& ParentWidgetName, + const FString& CatalogComponentId); + + /** Batch-render multiple widget variants; returns JSON array of preview results. */ + FString RenderVariants( + UWidgetBlueprint* WidgetBlueprint, + const TArray& WidgetNames, + int32 ViewportWidth, + int32 ViewportHeight, + const FString& Theme); + +private: + bool EncodeRenderTargetToBase64Png(UTextureRenderTarget2D* RenderTarget, FString& OutBase64, FString& OutError) const; + bool LoadCatalogVariants(const FString& CatalogComponentId, const FString& AssetPath, TArray& OutVariants, FString& OutError) const; + FString GetCatalogPath() const; +}; diff --git a/Source/UmgMcp/Public/Theme/UmgThemeSubsystem.h b/Source/UmgMcp/Public/Theme/UmgThemeSubsystem.h new file mode 100644 index 0000000..d1c44b3 --- /dev/null +++ b/Source/UmgMcp/Public/Theme/UmgThemeSubsystem.h @@ -0,0 +1,55 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#pragma once + +#include "CoreMinimal.h" +#include "EditorSubsystem.h" +#include "UmgThemeSubsystem.generated.h" + +DECLARE_LOG_CATEGORY_EXTERN(LogUmgTheme, Log, All); + +/** + * Loads and caches design tokens from theme.json. + * Supports @token references in set_widget_properties payloads. + */ +UCLASS() +class UMGMCP_API UUmgThemeSubsystem : public UEditorSubsystem +{ + GENERATED_BODY() + +public: + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + + /** Resolve "@color.primary" to a concrete value string (e.g. "0.1, 0.2, 0.8, 1.0"). */ + UFUNCTION(BlueprintCallable, Category = "UMG MCP|Theme") + FString ResolveToken(const FString& TokenRef) const; + + /** Recursively resolve @token strings inside a JSON object (in-place). */ + void ProcessJsonTokens(TSharedPtr& InOutJson, int32 Depth = 0) const; + + static constexpr int32 MaxTokenProcessDepth = 64; + + /** Reload theme cache from disk. Returns false when no theme file exists. */ + UFUNCTION(BlueprintCallable, Category = "UMG MCP|Theme") + bool ReloadThemeCache(const FString& OptionalAssetPath = TEXT("")); + + /** Merge patch object into theme and persist to disk, then reload cache. */ + bool ApplyThemePatch(const TSharedPtr& PatchObject, const FString& OptionalAssetPath = TEXT("")); + + /** Serialize cached theme to JSON string. */ + FString GetThemeJsonString() const; + + /** Unreal asset path for the active theme file. */ + FString GetActiveThemeAssetPath() const { return ActiveThemeAssetPath; } + +private: + FString ResolveThemeDiskPath(const FString& OptionalAssetPath) const; + FString GetDefaultThemeAssetPath() const; + bool LoadThemeFromDisk(const FString& DiskPath); + TSharedPtr NavigateTokenPath(const FString& DotPath) const; + TSharedPtr ResolveTokenValue(const FString& TokenRef) const; + void ProcessJsonValue(TSharedPtr& Value, int32 Depth = 0) const; + + TSharedPtr CachedTheme; + FString ActiveThemeAssetPath; +}; diff --git a/Source/UmgMcp/Public/Widget/UmgMcpPropertyJsonUtils.h b/Source/UmgMcp/Public/Widget/UmgMcpPropertyJsonUtils.h new file mode 100644 index 0000000..6878dfe --- /dev/null +++ b/Source/UmgMcp/Public/Widget/UmgMcpPropertyJsonUtils.h @@ -0,0 +1,26 @@ +// Copyright (c) 2025-2026 Winyunq. All rights reserved. +#pragma once + +#include "CoreMinimal.h" + +class FProperty; +class UPanelSlot; +class UWidget; + +/** + * Safe JSON export helpers for UMG widget/slot properties. + * Prevents UObject cycles (Widget -> Slot -> Content -> Widget) from blowing the stack + * inside FJsonObjectConverter::UPropertyToJsonValue. + */ +class UMGMCP_API FUmgMcpPropertyJsonUtils +{ +public: + /** Export editable slot fields, always skipping Content/Parent back-links. */ + static TSharedPtr SerializePanelSlotProperties(UPanelSlot* SlotObject, bool bOnlyNonDefault = true); + + /** Drop-in replacement for FJsonObjectConverter::UPropertyToJsonValue on UMG objects. */ + static TSharedPtr PropertyToJsonValue(FProperty* Property, const void* ValuePtr, int32 Depth = 0); + + /** Query helper: write slot layout JSON for a widget (Position/Size aliases handled elsewhere). */ + static bool TryWriteSlotPropertiesField(TSharedPtr& OutJson, const FString& FieldName, UWidget* Widget); +}; diff --git a/Source/UmgMcp/Public/Widget/UmgSetSubsystem.h b/Source/UmgMcp/Public/Widget/UmgSetSubsystem.h index 41dc427..7c470ea 100644 --- a/Source/UmgMcp/Public/Widget/UmgSetSubsystem.h +++ b/Source/UmgMcp/Public/Widget/UmgSetSubsystem.h @@ -28,12 +28,12 @@ class UMGMCP_API UUmgSetSubsystem : public UEditorSubsystem /** * @brief Sets or updates properties of a specified widget in a UWidgetBlueprint. * - * This function performs an incremental "union write" (merging properties). - * Only the properties explicitly specified in the incoming JSON payload will be modified; + * This function performs an incremental "union write" (merging properties). + * Only the properties explicitly specified in the incoming JSON payload will be modified; * all unspecified properties will remain untouched and retain their existing values. * * @param WidgetBlueprint The target widget blueprint containing the widget. - * @param WidgetName The name of the widget to edit. If empty or omitted at the command level, + * @param WidgetName The name of the widget to edit. If empty or omitted at the command level, * the active target widget currently focused in the attention context will be used. * @param PropertiesJson A JSON formatted string containing the properties to modify. * @return True if the properties were successfully set and applied, false otherwise. @@ -50,7 +50,7 @@ class UMGMCP_API UUmgSetSubsystem : public UEditorSubsystem /** * @brief Moves a widget under a new target parent container. * - * This acts as a standard drag-and-drop operation. The widget is removed from its + * This acts as a standard drag-and-drop operation. The widget is removed from its * old parent and appended to the target parent container. * * @param WidgetBlueprint The target widget blueprint containing the widgets. @@ -66,14 +66,14 @@ class UMGMCP_API UUmgSetSubsystem : public UEditorSubsystem * @brief Performs in-place replacement by wrapping a widget with a new container. * * This function creates a new container widget based on the provided JSON specification, - * inserts it into the target widget's original hierarchy position (inheriting the target's + * inserts it into the target widget's original hierarchy position (inheriting the target's * original parent slot layout properties), and parents the target widget under it. * * @param WidgetBlueprint The target widget blueprint. * @param WidgetName The name of the widget to wrap. If empty, the current active target is used. * @param NewParentWidgetJson A JSON string describing the new parent container to construct. * Must contain at least "widget_class" / "widget_type". - * @return TArray An array containing the names of widgets whose slot/layout parameters + * @return TArray An array containing the names of widgets whose slot/layout parameters * were structurally affected (usually includes target widget and wrapper). **/ UFUNCTION(BlueprintCallable, Category = "UMG MCP|Set") @@ -81,4 +81,4 @@ class UMGMCP_API UUmgSetSubsystem : public UEditorSubsystem UFUNCTION(BlueprintCallable, Category = "UMG MCP|Set") bool SaveAsset(class UWidgetBlueprint* WidgetBlueprint); -}; \ No newline at end of file +}; diff --git a/Source/UmgMcp/UmgMcp.Build.cs b/Source/UmgMcp/UmgMcp.Build.cs index 11560c9..d7be0df 100644 --- a/Source/UmgMcp/UmgMcp.Build.cs +++ b/Source/UmgMcp/UmgMcp.Build.cs @@ -41,7 +41,10 @@ public UmgMcp(ReadOnlyTargetRules Target) : base(Target) "UMG", // Add this line "MovieScene", "MovieSceneTracks", - "MaterialEditor" + "MaterialEditor", + "RenderCore", + "ImageWrapper", + "RHI" } ); diff --git a/UmgMcp.uplugin b/UmgMcp.uplugin index 2b8db57..1404cb3 100644 --- a/UmgMcp.uplugin +++ b/UmgMcp.uplugin @@ -10,7 +10,7 @@ "DocsURL": "https://github.com/winyunq/UnrealMotionGraphicsMCP", "MarketplaceURL": "", "SupportURL": "", - "EngineVersion": "5.6.0", + "EngineVersion": "5.8.0", "CanContainContent": true, "IsBetaVersion": false, "IsExperimentalVersion": false, @@ -25,5 +25,10 @@ ] } ], - "Plugins": [] -} \ No newline at end of file + "Plugins": [ + { + "Name": "EditorScriptingUtilities", + "Enabled": true + } + ] +}