Files
2026-07-13 12:49:17 +08:00

1913 lines
77 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Xml;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Runtime.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
namespace MCPForUnity.Editor.Tools
{
[McpForUnityTool("manage_ui", AutoRegister = false, Group = "ui")]
public static class ManageUI
{
private static readonly HashSet<string> ValidExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".uxml", ".uss"
};
// UTF-8 without BOM — UI Builder in Unity 6 can fail to open UXML files with a BOM.
private static readonly Encoding Utf8NoBom = new UTF8Encoding(false);
static ManageUI()
{
EditorApplication.quitting += CleanupRenderTextures;
AssemblyReloadEvents.beforeAssemblyReload += CleanupRenderTextures;
}
private static void CleanupRenderTextures()
{
foreach (var kvp in s_panelRTs)
{
if (kvp.Value == null) continue;
string assetPath = AssetDatabase.GetAssetPath(kvp.Value);
kvp.Value.Release();
if (!string.IsNullOrEmpty(assetPath))
AssetDatabase.DeleteAsset(assetPath);
else
UnityEngine.Object.DestroyImmediate(kvp.Value);
}
s_panelRTs.Clear();
}
public static object HandleCommand(JObject @params)
{
string action = @params["action"]?.ToString()?.ToLowerInvariant();
if (string.IsNullOrEmpty(action))
{
return new ErrorResponse("Action is required");
}
try
{
switch (action)
{
case "ping":
return new SuccessResponse("pong", new { tool = "manage_ui" });
case "create":
return CreateFile(@params);
case "read":
return ReadFile(@params);
case "update":
return UpdateFile(@params);
case "attach_ui_document":
return AttachUIDocument(@params);
case "create_panel_settings":
return CreatePanelSettings(@params);
case "update_panel_settings":
return UpdatePanelSettings(@params);
case "get_visual_tree":
return GetVisualTree(@params);
case "render_ui":
return RenderUI(@params);
case "link_stylesheet":
return LinkStylesheet(@params);
case "delete":
return DeleteFile(@params);
case "list":
return ListUIAssets(@params);
case "detach_ui_document":
return DetachUIDocument(@params);
case "modify_visual_element":
return ModifyVisualElement(@params);
default:
return new ErrorResponse($"Unknown action: {action}");
}
}
catch (Exception ex)
{
return new ErrorResponse(ex.Message, new { stackTrace = ex.StackTrace });
}
}
private static string ValidatePath(string path, out string error)
{
error = null;
if (string.IsNullOrEmpty(path))
{
error = "'path' parameter is required.";
return null;
}
path = AssetPathUtility.SanitizeAssetPath(path);
if (path == null)
{
error = "Invalid path: contains traversal sequences.";
return null;
}
string ext = Path.GetExtension(path);
if (!ValidExtensions.Contains(ext))
{
error = $"Invalid file extension '{ext}'. Must be .uxml or .uss.";
return null;
}
return path;
}
private static object CreateFile(JObject @params)
{
var p = new ToolParams(@params);
string path = ValidatePath(p.Get("path"), out string pathError);
if (pathError != null) return new ErrorResponse(pathError);
string contents;
try
{
contents = GetDecodedContents(p);
}
catch (ArgumentException ex)
{
return new ErrorResponse(ex.Message);
}
if (contents == null)
{
return new ErrorResponse("'contents' parameter is required for create.");
}
string fullPath = Path.Combine(Application.dataPath,
path.Substring("Assets/".Length));
fullPath = fullPath.Replace('/', Path.DirectorySeparatorChar);
if (File.Exists(fullPath))
{
return new ErrorResponse($"File already exists at {path}. Use 'update' action to overwrite.");
}
string dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
bool isUxml = path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase);
var validationWarnings = new List<string>();
if (isUxml)
{
string xmlError = ValidateUxmlContent(contents, validationWarnings);
if (xmlError != null)
{
return new ErrorResponse($"UXML validation failed — file was NOT written. {xmlError}");
}
contents = EnsureEditorExtensionMode(contents);
}
File.WriteAllText(fullPath, contents, Utf8NoBom);
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
if (isUxml)
{
ValidateUxmlPostImport(path, validationWarnings);
}
string ext = Path.GetExtension(path).TrimStart('.');
if (validationWarnings.Count > 0)
{
return new SuccessResponse(
$"Created {ext} file at {path} with {validationWarnings.Count} warning(s)",
new { path, validationWarnings });
}
return new SuccessResponse($"Created {ext} file at {path}",
new { path });
}
private static object ReadFile(JObject @params)
{
var p = new ToolParams(@params);
string path = ValidatePath(p.Get("path"), out string pathError);
if (pathError != null) return new ErrorResponse(pathError);
string fullPath = Path.Combine(Application.dataPath,
path.Substring("Assets/".Length));
fullPath = fullPath.Replace('/', Path.DirectorySeparatorChar);
if (!File.Exists(fullPath))
{
return new ErrorResponse($"File not found: {path}");
}
string contents = File.ReadAllText(fullPath, Encoding.UTF8);
string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(contents));
return new SuccessResponse($"Read {Path.GetExtension(path).TrimStart('.')} file at {path}",
new
{
path,
contents,
encodedContents = encoded,
contentsEncoded = true,
lengthBytes = Encoding.UTF8.GetByteCount(contents)
});
}
private static object UpdateFile(JObject @params)
{
var p = new ToolParams(@params);
string path = ValidatePath(p.Get("path"), out string pathError);
if (pathError != null) return new ErrorResponse(pathError);
string contents;
try
{
contents = GetDecodedContents(p);
}
catch (ArgumentException ex)
{
return new ErrorResponse(ex.Message);
}
if (contents == null)
{
return new ErrorResponse("'contents' parameter is required for update.");
}
string fullPath = Path.Combine(Application.dataPath,
path.Substring("Assets/".Length));
fullPath = fullPath.Replace('/', Path.DirectorySeparatorChar);
if (!File.Exists(fullPath))
{
return new ErrorResponse($"File not found: {path}. Use 'create' action for new files.");
}
bool isUxml = path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase);
var validationWarnings = new List<string>();
if (isUxml)
{
string xmlError = ValidateUxmlContent(contents, validationWarnings);
if (xmlError != null)
{
return new ErrorResponse($"UXML validation failed — file was NOT updated. {xmlError}");
}
contents = EnsureEditorExtensionMode(contents);
}
File.WriteAllText(fullPath, contents, Utf8NoBom);
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
if (isUxml)
{
ValidateUxmlPostImport(path, validationWarnings);
}
string ext = Path.GetExtension(path).TrimStart('.');
if (validationWarnings.Count > 0)
{
return new SuccessResponse(
$"Updated {ext} file at {path} with {validationWarnings.Count} warning(s)",
new { path, validationWarnings });
}
return new SuccessResponse($"Updated {ext} file at {path}",
new { path });
}
private static object AttachUIDocument(JObject @params)
{
var p = new ToolParams(@params);
var targetResult = p.GetRequired("target");
var targetError = targetResult.GetOrError(out string target);
if (targetError != null) return targetError;
var sourceResult = p.GetRequired("source_asset");
var sourceError = sourceResult.GetOrError(out string sourceAssetPath);
if (sourceError != null) return sourceError;
sourceAssetPath = AssetPathUtility.SanitizeAssetPath(sourceAssetPath);
if (sourceAssetPath == null)
{
return new ErrorResponse("Invalid source_asset path.");
}
// Find the GameObject
var goInstruction = new JObject { ["find"] = target };
GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject;
if (go == null)
{
return new ErrorResponse($"Could not find target GameObject: {target}");
}
// Load the VisualTreeAsset
var vta = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(sourceAssetPath);
if (vta == null)
{
return new ErrorResponse($"Could not load VisualTreeAsset at: {sourceAssetPath}");
}
// Load or create PanelSettings
string panelSettingsPath = p.Get("panel_settings") ?? p.Get("panelSettings");
PanelSettings panelSettings = null;
if (!string.IsNullOrEmpty(panelSettingsPath))
{
panelSettingsPath = AssetPathUtility.SanitizeAssetPath(panelSettingsPath);
if (panelSettingsPath != null)
{
panelSettings = AssetDatabase.LoadAssetAtPath<PanelSettings>(panelSettingsPath);
}
if (panelSettings == null)
{
return new ErrorResponse($"Could not load PanelSettings at: {panelSettingsPath}");
}
}
else
{
// Find existing or create default PanelSettings
string[] guids = AssetDatabase.FindAssets("t:PanelSettings");
if (guids.Length > 0)
{
string existingPath = AssetDatabase.GUIDToAssetPath(guids[0]);
panelSettings = AssetDatabase.LoadAssetAtPath<PanelSettings>(existingPath);
}
if (panelSettings == null)
{
panelSettings = CreateDefaultPanelSettings("Assets/UI/DefaultPanelSettings.asset");
if (panelSettings == null)
{
return new ErrorResponse("Failed to create default PanelSettings.");
}
}
}
Undo.RecordObject(go, "Attach UIDocument");
// Add or get UIDocument component
var uiDoc = go.GetComponent<UIDocument>();
if (uiDoc == null)
{
uiDoc = Undo.AddComponent<UIDocument>(go);
}
uiDoc.visualTreeAsset = vta;
uiDoc.panelSettings = panelSettings;
int sortOrder = p.GetInt("sort_order") ?? 0;
uiDoc.sortingOrder = sortOrder;
EditorUtility.SetDirty(go);
return new SuccessResponse($"Attached UIDocument to {go.name}",
new
{
gameObject = go.name,
sourceAsset = sourceAssetPath,
panelSettings = AssetDatabase.GetAssetPath(panelSettings),
sortOrder
});
}
private static object CreatePanelSettings(JObject @params)
{
var p = new ToolParams(@params);
var pathResult = p.GetRequired("path");
var pathError = pathResult.GetOrError(out string path);
if (pathError != null) return pathError;
path = AssetPathUtility.SanitizeAssetPath(path);
if (path == null)
{
return new ErrorResponse("Invalid path: contains traversal sequences.");
}
if (!path.EndsWith(".asset", StringComparison.OrdinalIgnoreCase))
{
path += ".asset";
}
if (AssetDatabase.LoadAssetAtPath<PanelSettings>(path) != null)
{
return new ErrorResponse($"PanelSettings already exists at {path}");
}
var ps = CreateDefaultPanelSettings(path);
if (ps == null)
{
return new ErrorResponse("Failed to create PanelSettings asset.");
}
// Apply any settings passed as a flat dict
JToken settingsToken = p.GetRaw("settings");
var changes = new List<string>();
if (settingsToken is JObject settingsObj)
{
ApplyPanelSettingsProperties(ps, settingsObj, changes);
}
else
{
// Legacy: support top-level scale_mode / reference_resolution
string scaleMode = p.Get("scale_mode");
if (!string.IsNullOrEmpty(scaleMode))
{
if (Enum.TryParse<PanelScaleMode>(scaleMode, true, out var mode))
{
ps.scaleMode = mode;
changes.Add("scaleMode");
}
}
JToken refResToken = p.GetRaw("reference_resolution");
if (refResToken is JObject refRes)
{
int w = refRes["width"]?.ToObject<int>() ?? 1920;
int h = refRes["height"]?.ToObject<int>() ?? 1080;
ps.referenceResolution = new Vector2Int(w, h);
changes.Add("referenceResolution");
}
}
EditorUtility.SetDirty(ps);
AssetDatabase.SaveAssets();
return new SuccessResponse($"Created PanelSettings at {path}",
new { path, applied = changes });
}
private static object UpdatePanelSettings(JObject @params)
{
var p = new ToolParams(@params);
var pathResult = p.GetRequired("path");
var pathError = pathResult.GetOrError(out string path);
if (pathError != null) return pathError;
path = AssetPathUtility.SanitizeAssetPath(path);
if (path == null)
return new ErrorResponse("Invalid path: contains traversal sequences.");
if (!path.EndsWith(".asset", StringComparison.OrdinalIgnoreCase))
path += ".asset";
var ps = AssetDatabase.LoadAssetAtPath<PanelSettings>(path);
if (ps == null)
return new ErrorResponse($"No PanelSettings found at {path}");
JToken settingsToken = p.GetRaw("settings");
if (settingsToken is not JObject settingsObj || settingsObj.Count == 0)
return new ErrorResponse("'settings' dict is required with at least one property to update.");
var changes = new List<string>();
ApplyPanelSettingsProperties(ps, settingsObj, changes);
if (changes.Count == 0)
return new ErrorResponse("No recognised properties were applied. Check the key names.");
EditorUtility.SetDirty(ps);
AssetDatabase.SaveAssets();
return new SuccessResponse($"Updated PanelSettings at {path}",
new { path, applied = changes });
}
private static PanelSettings CreateDefaultPanelSettings(string path)
{
string dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
{
EnsureFolderExists(dir);
}
var ps = ScriptableObject.CreateInstance<PanelSettings>();
AssetDatabase.CreateAsset(ps, path);
AssetDatabase.SaveAssets();
return ps;
}
/// <summary>
/// Generic, data-driven applicator for PanelSettings properties.
/// Accepts a flat JObject where each key maps to a PanelSettings property.
/// Recognised keys (case-insensitive matching via snake_case/camelCase):
/// scaleMode, referenceResolution, screenMatchMode, match,
/// referenceDpi, fallbackDpi, sortingOrder, targetDisplay,
/// clearColor, colorClearValue, clearDepthStencil,
/// themeStyleSheet, dynamicAtlasSettings.
/// </summary>
private static void ApplyPanelSettingsProperties(PanelSettings ps, JObject settings, List<string> changes)
{
foreach (var prop in settings)
{
string key = NormalizeKey(prop.Key);
JToken val = prop.Value;
switch (key)
{
// ── Enum properties ─────────────────────────────────────
case "scalemode":
if (TryParseEnum<PanelScaleMode>(val, out var sm)) { ps.scaleMode = sm; changes.Add("scaleMode"); }
break;
case "screenmatchmode":
if (TryParseEnum<PanelScreenMatchMode>(val, out var smm)) { ps.screenMatchMode = smm; changes.Add("screenMatchMode"); }
break;
// ── Numeric properties ──────────────────────────────────
case "match":
if (TryFloat(val, out float matchVal)) { ps.match = Mathf.Clamp01(matchVal); changes.Add("match"); }
break;
case "referencedpi":
if (TryFloat(val, out float refDpi)) { ps.referenceDpi = refDpi; changes.Add("referenceDpi"); }
break;
case "fallbackdpi":
if (TryFloat(val, out float fbDpi)) { ps.fallbackDpi = fbDpi; changes.Add("fallbackDpi"); }
break;
case "sortingorder":
if (TryInt(val, out int so)) { ps.sortingOrder = so; changes.Add("sortingOrder"); }
break;
case "targetdisplay":
if (TryInt(val, out int td)) { ps.targetDisplay = td; changes.Add("targetDisplay"); }
break;
// ── Bool properties ──────────────────────────────────────
case "clearcolor":
ps.clearColor = ParamCoercion.CoerceBool(val, false);
changes.Add("clearColor");
break;
case "cleardepthstencil":
ps.clearDepthStencil = ParamCoercion.CoerceBool(val, false);
changes.Add("clearDepthStencil");
break;
// ── Composite properties ────────────────────────────────
case "referenceresolution":
if (val is JObject resObj)
{
int w = resObj["width"]?.ToObject<int>() ?? ps.referenceResolution.x;
int h = resObj["height"]?.ToObject<int>() ?? ps.referenceResolution.y;
ps.referenceResolution = new Vector2Int(w, h);
changes.Add("referenceResolution");
}
break;
case "colorclearvalue":
if (TryParseColor(val, out Color clr)) { ps.colorClearValue = clr; changes.Add("colorClearValue"); }
break;
case "dynamicatlassettings":
if (val is JObject daObj) { ApplyDynamicAtlasSettings(ps, daObj, changes); }
break;
// ── Asset reference properties ──────────────────────────
case "themestylesheet":
{
string tsPath = val?.ToString();
if (!string.IsNullOrEmpty(tsPath))
{
var ts = AssetDatabase.LoadAssetAtPath<ThemeStyleSheet>(tsPath);
if (ts != null) { ps.themeStyleSheet = ts; changes.Add("themeStyleSheet"); }
}
break;
}
// unknown keys are silently ignored
}
}
}
private static void ApplyDynamicAtlasSettings(PanelSettings ps, JObject da, List<string> changes)
{
var daCopy = ps.dynamicAtlasSettings;
if (da["minAtlasSize"] != null && TryInt(da["minAtlasSize"], out int minSize))
daCopy.minAtlasSize = minSize;
if (da["maxAtlasSize"] != null && TryInt(da["maxAtlasSize"], out int maxSize))
daCopy.maxAtlasSize = maxSize;
if (da["maxSubTextureSize"] != null && TryInt(da["maxSubTextureSize"], out int maxSub))
daCopy.maxSubTextureSize = maxSub;
if (da["activeFilters"] != null && TryParseEnum<DynamicAtlasFilters>(da["activeFilters"], out var af))
daCopy.activeFilters = af;
ps.dynamicAtlasSettings = daCopy;
changes.Add("dynamicAtlasSettings");
}
// ── Tiny helpers to keep the switch compact ─────────────────────────
private static string NormalizeKey(string key)
{
// Strip underscores and lowercase so "scale_mode", "scaleMode", "ScaleMode"
// all match the same case label.
return key.Replace("_", "").ToLowerInvariant();
}
private static bool TryParseEnum<T>(JToken token, out T result) where T : struct, Enum
{
result = default;
string s = token?.ToString();
return !string.IsNullOrEmpty(s) && Enum.TryParse(s, true, out result);
}
private static bool TryFloat(JToken token, out float result)
{
result = 0f;
if (token == null) return false;
if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer)
{
result = token.ToObject<float>();
return true;
}
return float.TryParse(token.ToString(), out result);
}
private static bool TryInt(JToken token, out int result)
{
result = 0;
if (token == null) return false;
if (token.Type == JTokenType.Integer)
{
result = token.ToObject<int>();
return true;
}
return int.TryParse(token.ToString(), out result);
}
private static bool TryParseColor(JToken token, out Color color)
{
color = Color.clear;
if (token == null) return false;
// Accept "#RRGGBB", "#RRGGBBAA", or {r,g,b,a} object
if (token.Type == JTokenType.String)
{
return ColorUtility.TryParseHtmlString(token.ToString(), out color);
}
if (token is JObject cObj)
{
color = new Color(
cObj["r"]?.ToObject<float>() ?? 0f,
cObj["g"]?.ToObject<float>() ?? 0f,
cObj["b"]?.ToObject<float>() ?? 0f,
cObj["a"]?.ToObject<float>() ?? 1f
);
return true;
}
return false;
}
private static void EnsureFolderExists(string assetFolderPath)
{
if (AssetDatabase.IsValidFolder(assetFolderPath))
return;
string[] parts = assetFolderPath.Replace('\\', '/').Split('/');
string current = parts[0]; // "Assets"
for (int i = 1; i < parts.Length; i++)
{
string next = current + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next))
{
AssetDatabase.CreateFolder(current, parts[i]);
}
current = next;
}
}
private static object GetVisualTree(JObject @params)
{
var p = new ToolParams(@params);
var targetResult = p.GetRequired("target");
var targetError = targetResult.GetOrError(out string target);
if (targetError != null) return targetError;
int maxDepth = p.GetInt("max_depth") ?? 10;
var goInstruction = new JObject { ["find"] = target };
GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject;
if (go == null)
{
return new ErrorResponse($"Could not find target GameObject: {target}");
}
var uiDoc = go.GetComponent<UIDocument>();
if (uiDoc == null)
{
return new ErrorResponse($"GameObject {go.name} has no UIDocument component.");
}
var root = uiDoc.rootVisualElement;
if (root == null)
{
return new SuccessResponse($"UIDocument on {go.name} has no visual tree (not yet built).",
new
{
gameObject = go.name,
sourceAsset = uiDoc.visualTreeAsset != null
? AssetDatabase.GetAssetPath(uiDoc.visualTreeAsset)
: null,
tree = (object)null
});
}
var tree = SerializeVisualElement(root, 0, maxDepth);
return new SuccessResponse($"Visual tree for UIDocument on {go.name}",
new
{
gameObject = go.name,
sourceAsset = uiDoc.visualTreeAsset != null
? AssetDatabase.GetAssetPath(uiDoc.visualTreeAsset)
: null,
tree
});
}
private static object SerializeVisualElement(VisualElement element, int depth, int maxDepth)
{
var result = new Dictionary<string, object>
{
["type"] = element.GetType().Name,
["name"] = element.name ?? "",
["classes"] = new List<string>(element.GetClasses()),
};
// Include basic computed style info
var style = new Dictionary<string, object>();
var resolved = element.resolvedStyle;
if (resolved.width > 0) style["width"] = resolved.width;
if (resolved.height > 0) style["height"] = resolved.height;
if (resolved.color != Color.clear)
style["color"] = ColorToHex(resolved.color);
if (resolved.backgroundColor != Color.clear)
style["backgroundColor"] = ColorToHex(resolved.backgroundColor);
if (resolved.fontSize > 0) style["fontSize"] = resolved.fontSize;
if (style.Count > 0)
result["resolvedStyle"] = style;
// Include text content for labels/buttons
if (element is TextElement textEl && !string.IsNullOrEmpty(textEl.text))
{
result["text"] = textEl.text;
}
// Serialize children
if (depth < maxDepth && element.childCount > 0)
{
var children = new List<object>();
foreach (var child in element.Children())
{
children.Add(SerializeVisualElement(child, depth + 1, maxDepth));
}
result["children"] = children;
}
else if (element.childCount > 0)
{
result["childCount"] = element.childCount;
result["truncated"] = true;
}
return result;
}
// ---- Render UI ----
// Persistent RenderTextures keyed by PanelSettings instance ID so the panel
// renders into them automatically every frame.
private static readonly Dictionary<int, RenderTexture> s_panelRTs = new();
// Play-mode coroutine capture state. Only one capture is in-flight at a
// time; concurrent render_ui calls while a capture is pending are rejected
// with an explicit error.
private static Texture2D s_pendingCaptureTex;
private static bool s_pendingCaptureDone;
private static bool s_pendingCaptureStarted;
private static object RenderUI(JObject @params)
{
var p = new ToolParams(@params);
string target = p.Get("target");
string uxmlPath = p.Get("path");
int width = p.GetInt("width") ?? 1920;
int height = p.GetInt("height") ?? 1080;
bool includeImage = p.GetBool("include_image") || p.GetBool("includeImage");
int maxResolution = p.GetInt("max_resolution") ?? p.GetInt("maxResolution") ?? 640;
string fileName = p.Get("file_name") ?? p.Get("fileName");
string outputFolderOverride = p.Get("output_folder") ?? p.Get("outputFolder");
if (string.IsNullOrEmpty(target) && string.IsNullOrEmpty(uxmlPath))
{
return new ErrorResponse("Either 'target' (GameObject with UIDocument) or 'path' (UXML asset path) is required.");
}
string resolvedFolderSpec = ScreenshotPreferences.Resolve(outputFolderOverride);
string resolvedFolderAbs;
try
{
resolvedFolderAbs = ScreenshotUtility.ResolveFolderAbsolute(resolvedFolderSpec);
}
catch (InvalidOperationException ex)
{
return new ErrorResponse(ex.Message);
}
// ── Play-mode capture via ScreenCapture coroutine ──────────────────────
// PanelSettings.targetTexture is read in the same frame it is assigned,
// so the RT is always blank in a synchronous tool call. In play mode we
// dispatch a WaitForEndOfFrame coroutine that uses ScreenCapture, which
// captures the fully-composited game view (including UI Toolkit overlays).
// First call: queues the capture and returns "pending".
// Second call: result is ready save PNG and return data.
if (Application.isPlaying)
{
// Build the output paths (used by both the pending and ready branches)
string resolvedPlayName = string.IsNullOrWhiteSpace(fileName)
? $"ui-render-{DateTime.Now:yyyyMMdd-HHmmss}.png"
: fileName.Trim();
if (!resolvedPlayName.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
resolvedPlayName += ".png";
Directory.CreateDirectory(resolvedFolderAbs);
string playFullPath = Path.Combine(resolvedFolderAbs, resolvedPlayName).Replace('\\', '/');
playFullPath = EnsureUniqueFilePath(playFullPath);
string playProjectRelPath = ScreenshotUtility.ToProjectRelativePath(playFullPath);
// ── Case 1: capture is ready ──────────────────────────────────────
if (s_pendingCaptureDone && s_pendingCaptureTex != null)
{
var captureTex = s_pendingCaptureTex;
s_pendingCaptureDone = false;
s_pendingCaptureTex = null;
int captureW = captureTex.width;
int captureH = captureTex.height;
byte[] capturePng = captureTex.EncodeToPNG();
UnityEngine.Object.DestroyImmediate(captureTex);
File.WriteAllBytes(playFullPath, capturePng);
if (ScreenshotUtility.IsUnderAssets(playProjectRelPath))
AssetDatabase.ImportAsset(playProjectRelPath, ImportAssetOptions.ForceSynchronousImport);
var playData = new Dictionary<string, object>
{
{ "path", playProjectRelPath },
{ "fullPath", playFullPath },
{ "width", captureW },
{ "height", captureH },
{ "hasContent", true },
};
if (!string.IsNullOrEmpty(target)) playData["gameObject"] = target;
if (!string.IsNullOrEmpty(uxmlPath)) playData["sourceAsset"] = uxmlPath;
if (includeImage)
{
int targetMax = maxResolution > 0 ? maxResolution : 640;
Texture2D downscaled = null;
try
{
var fullTex = new Texture2D(captureW, captureH, TextureFormat.RGBA32, false);
fullTex.LoadImage(capturePng);
if (captureW > targetMax || captureH > targetMax)
{
downscaled = ScreenshotUtility.DownscaleTexture(fullTex, targetMax);
playData["imageBase64"] = Convert.ToBase64String(downscaled.EncodeToPNG());
playData["imageWidth"] = downscaled.width;
playData["imageHeight"] = downscaled.height;
}
else
{
playData["imageBase64"] = Convert.ToBase64String(capturePng);
playData["imageWidth"] = captureW;
playData["imageHeight"] = captureH;
}
UnityEngine.Object.DestroyImmediate(fullTex);
}
finally
{
if (downscaled != null) UnityEngine.Object.DestroyImmediate(downscaled);
}
}
return new SuccessResponse($"UI render saved to '{playProjectRelPath}'.", playData);
}
// ── Case 2: start a new capture ───────────────────────────────────
// Only one capture in flight at a time. If one is already pending,
// reject rather than silently overwriting the state.
if (s_pendingCaptureStarted)
{
return new ErrorResponse(
"Cannot capture: another capture is already in progress.",
new { retry_after_ms = 100, reason = "capture_in_progress" });
}
s_pendingCaptureDone = false;
s_pendingCaptureTex = null;
s_pendingCaptureStarted = true;
ScreenshotCapturer.Begin(1, tex =>
{
s_pendingCaptureTex = tex;
s_pendingCaptureDone = true;
s_pendingCaptureStarted = false;
});
return new SuccessResponse(
"Play-mode screenshot capture queued (WaitForEndOfFrame). Call render_ui again to retrieve the rendered image.",
new Dictionary<string, object>
{
{ "pending", true },
{ "gameObject", (object)target ?? uxmlPath },
{ "note", "A screen capture was scheduled for the end of this frame. Call render_ui once more to get the result." }
});
}
// ── End play-mode branch ────────────────────────────────────────────────
// Resolve UIDocument
UIDocument uiDoc = null;
GameObject tempGo = null;
PanelSettings tempPs = null;
try
{
if (!string.IsNullOrEmpty(target))
{
var goInstruction = new JObject { ["find"] = target };
GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject;
if (go == null)
return new ErrorResponse($"Could not find target GameObject: {target}");
uiDoc = go.GetComponent<UIDocument>();
if (uiDoc == null)
return new ErrorResponse($"GameObject '{go.name}' has no UIDocument component.");
}
else
{
uxmlPath = AssetPathUtility.SanitizeAssetPath(uxmlPath);
if (uxmlPath == null)
return new ErrorResponse("Invalid UXML path.");
var vta = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(uxmlPath);
if (vta == null)
return new ErrorResponse($"Could not load VisualTreeAsset at: {uxmlPath}");
tempGo = new GameObject("__MCP_UI_Render_Temp__");
tempGo.hideFlags = HideFlags.HideAndDontSave;
uiDoc = tempGo.AddComponent<UIDocument>();
string[] guids = AssetDatabase.FindAssets("t:PanelSettings");
PanelSettings ps = null;
if (guids.Length > 0)
ps = AssetDatabase.LoadAssetAtPath<PanelSettings>(AssetDatabase.GUIDToAssetPath(guids[0]));
if (ps == null)
{
ps = CreateDefaultPanelSettings("Assets/UI/DefaultPanelSettings.asset");
tempPs = ps;
}
uiDoc.panelSettings = ps;
uiDoc.visualTreeAsset = vta;
}
if (uiDoc.panelSettings == null)
return new ErrorResponse("UIDocument has no PanelSettings assigned.");
var panelSettings = uiDoc.panelSettings;
int psId = panelSettings.GetInstanceIDCompat();
// Check if we already have a persistent RT assigned to this PanelSettings.
// If the RT exists and its size matches, the panel has been rendering into it.
// If not, create one and assign it — content will be available on the next call.
// Look up from our cache rather than panelSettings.targetTexture,
// because we set targetTexture = null after each successful read
// to restore on-screen rendering. The RT itself stays alive in s_panelRTs.
bool rtJustAssigned = false;
RenderTexture rt = null;
if (s_panelRTs.TryGetValue(psId, out var cachedRt) && cachedRt != null)
{
if (cachedRt.width == width && cachedRt.height == height)
{
rt = cachedRt;
// Re-attach if it was detached after the previous read
if (panelSettings.targetTexture != rt)
{
panelSettings.targetTexture = rt;
rtJustAssigned = true;
uiDoc.rootVisualElement?.MarkDirtyRepaint();
EditorUtility.SetDirty(panelSettings);
UnityEditorInternal.InternalEditorUtility.RepaintAllViews();
Canvas.ForceUpdateCanvases();
}
}
else
{
// Size changed — release the old RT
panelSettings.targetTexture = null;
string oldPath = AssetDatabase.GetAssetPath(cachedRt);
cachedRt.Release();
if (!string.IsNullOrEmpty(oldPath))
AssetDatabase.DeleteAsset(oldPath);
else
UnityEngine.Object.DestroyImmediate(cachedRt);
s_panelRTs.Remove(psId);
}
}
if (rt == null)
{
// Create RT as an asset so PanelSettings can serialize the reference properly
rt = new RenderTexture(width, height, 32, RenderTextureFormat.ARGB32);
rt.name = $"MCP_UI_Render_{psId}";
rt.Create();
string rtFolder = "Assets/UI";
if (!AssetDatabase.IsValidFolder(rtFolder))
AssetDatabase.CreateFolder("Assets", "UI");
string rtAssetPath = $"{rtFolder}/RT_MCP_UI_Render_{psId}.renderTexture";
AssetDatabase.CreateAsset(rt, rtAssetPath);
AssetDatabase.SaveAssets();
panelSettings.targetTexture = rt;
s_panelRTs[psId] = rt;
rtJustAssigned = true;
// Mark dirty and force editor repaint so the panel renders into the RT
uiDoc.rootVisualElement?.MarkDirtyRepaint();
EditorUtility.SetDirty(panelSettings);
UnityEditorInternal.InternalEditorUtility.RepaintAllViews();
// Force a synchronous layout + repaint pass
Canvas.ForceUpdateCanvases();
}
// Read pixels from the RT
RenderTexture prevActive = RenderTexture.active;
RenderTexture.active = rt;
var tex = new Texture2D(width, height, TextureFormat.RGBA32, false);
tex.ReadPixels(new Rect(0, 0, width, height), 0, 0);
tex.Apply();
RenderTexture.active = prevActive;
// Restore targetTexture to null so the UI renders back to the
// actual display / camera. The RT stays cached in s_panelRTs
// and will be re-attached on the next render_ui call.
if (!rtJustAssigned)
{
panelSettings.targetTexture = null;
EditorUtility.SetDirty(panelSettings);
}
// Check if any content was rendered
bool hasContent = false;
var pixels = tex.GetPixels32();
for (int i = 0; i < pixels.Length; i += Mathf.Max(1, pixels.Length / 100))
{
if (pixels[i].a > 0) { hasContent = true; break; }
}
// Save to Screenshots folder
string resolvedName = string.IsNullOrWhiteSpace(fileName)
? $"ui-render-{DateTime.Now:yyyyMMdd-HHmmss}.png"
: fileName.Trim();
if (!resolvedName.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
resolvedName += ".png";
Directory.CreateDirectory(resolvedFolderAbs);
string fullPath = Path.Combine(resolvedFolderAbs, resolvedName).Replace('\\', '/');
fullPath = EnsureUniqueFilePath(fullPath);
byte[] png = tex.EncodeToPNG();
File.WriteAllBytes(fullPath, png);
string projectRelPath = ScreenshotUtility.ToProjectRelativePath(fullPath);
if (ScreenshotUtility.IsUnderAssets(projectRelPath))
AssetDatabase.ImportAsset(projectRelPath, ImportAssetOptions.ForceSynchronousImport);
var data = new Dictionary<string, object>
{
{ "path", projectRelPath },
{ "fullPath", fullPath },
{ "width", width },
{ "height", height },
{ "hasContent", hasContent },
};
if (rtJustAssigned)
data["note"] = "RenderTexture was just assigned to PanelSettings. Call render_ui again to capture the rendered UI.";
if (!string.IsNullOrEmpty(target))
data["gameObject"] = target;
if (!string.IsNullOrEmpty(uxmlPath))
data["sourceAsset"] = uxmlPath;
if (includeImage)
{
int targetMax = maxResolution > 0 ? maxResolution : 640;
Texture2D downscaled = null;
try
{
if (width > targetMax || height > targetMax)
{
downscaled = ScreenshotUtility.DownscaleTexture(tex, targetMax);
data["imageBase64"] = Convert.ToBase64String(downscaled.EncodeToPNG());
data["imageWidth"] = downscaled.width;
data["imageHeight"] = downscaled.height;
}
else
{
data["imageBase64"] = Convert.ToBase64String(png);
data["imageWidth"] = width;
data["imageHeight"] = height;
}
}
finally
{
if (downscaled != null) UnityEngine.Object.DestroyImmediate(downscaled);
}
}
UnityEngine.Object.DestroyImmediate(tex);
string msg = hasContent
? $"UI rendered to '{projectRelPath}'."
: rtJustAssigned
? $"RenderTexture assigned to PanelSettings. Call render_ui again to capture the rendered content."
: $"UI render saved to '{projectRelPath}' (no visible content detected).";
return new SuccessResponse(msg, data);
}
finally
{
if (tempGo != null) UnityEngine.Object.DestroyImmediate(tempGo);
if (tempPs != null)
{
string tempPsPath = AssetDatabase.GetAssetPath(tempPs);
if (!string.IsNullOrEmpty(tempPsPath))
AssetDatabase.DeleteAsset(tempPsPath);
else
UnityEngine.Object.DestroyImmediate(tempPs, true);
}
}
}
// ---- Link Stylesheet ----
private static object LinkStylesheet(JObject @params)
{
var p = new ToolParams(@params);
string uxmlPathRaw = p.Get("path");
string uxmlPath = ValidatePath(uxmlPathRaw, out string pathError);
if (pathError != null) return new ErrorResponse(pathError);
// Validate the UXML path is actually a .uxml
if (!uxmlPath.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase))
return new ErrorResponse("'path' must point to a .uxml file.");
string stylesheetPath = p.Get("stylesheet");
if (string.IsNullOrEmpty(stylesheetPath))
return new ErrorResponse("'stylesheet' parameter is required.");
stylesheetPath = AssetPathUtility.SanitizeAssetPath(stylesheetPath);
if (stylesheetPath == null)
return new ErrorResponse("Invalid stylesheet path: contains traversal sequences.");
if (!stylesheetPath.EndsWith(".uss", StringComparison.OrdinalIgnoreCase))
return new ErrorResponse("'stylesheet' must point to a .uss file.");
// Read the UXML file
string fullPath = Path.Combine(Application.dataPath,
uxmlPath.Substring("Assets/".Length)).Replace('/', Path.DirectorySeparatorChar);
if (!File.Exists(fullPath))
return new ErrorResponse($"UXML file not found: {uxmlPath}");
string content = File.ReadAllText(fullPath, Encoding.UTF8);
// Check if stylesheet is already linked
if (content.Contains($"src=\"{stylesheetPath}\"") ||
content.Contains($"src=\"project://database/{stylesheetPath}\""))
{
return new SuccessResponse($"Stylesheet already linked in '{uxmlPath}'.",
new { path = uxmlPath, stylesheet = stylesheetPath, alreadyLinked = true });
}
// Find the insertion point (after the opening <ui:UXML ...> or <UXML ...> tag)
int insertIdx = FindUxmlBodyStart(content);
if (insertIdx < 0)
return new ErrorResponse("Could not find insertion point. Ensure UXML has a root <ui:UXML> or <UXML> element.");
string styleTag = $"\n <ui:Style src=\"project://database/{stylesheetPath}\" />";
content = content.Insert(insertIdx, styleTag);
File.WriteAllText(fullPath, content, Utf8NoBom);
AssetDatabase.ImportAsset(uxmlPath, ImportAssetOptions.ForceUpdate);
return new SuccessResponse($"Linked stylesheet '{stylesheetPath}' to '{uxmlPath}'.",
new { path = uxmlPath, stylesheet = stylesheetPath });
}
// ---- Delete ----
private static object DeleteFile(JObject @params)
{
var p = new ToolParams(@params);
string path = ValidatePath(p.Get("path"), out string pathError);
if (pathError != null) return new ErrorResponse(pathError);
string fullPath = Path.Combine(Application.dataPath,
path.Substring("Assets/".Length));
fullPath = fullPath.Replace('/', Path.DirectorySeparatorChar);
if (!File.Exists(fullPath))
{
return new ErrorResponse($"File not found: {path}");
}
try
{
bool success = AssetDatabase.DeleteAsset(path);
if (!success)
{
return new ErrorResponse($"Failed to delete file through AssetDatabase: '{path}'");
}
// Fallback: if file still exists after AssetDatabase.DeleteAsset
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
return new SuccessResponse($"Deleted {Path.GetExtension(path).TrimStart('.')} file at {path}",
new { path });
}
catch (Exception e)
{
return new ErrorResponse($"Failed to delete '{path}': {e.Message}");
}
}
// ---- List UI Assets ----
private static object ListUIAssets(JObject @params)
{
var p = new ToolParams(@params);
string scope = p.Get("path") ?? "Assets";
string filterType = p.Get("filter_type") ?? p.Get("filterType");
int pageSize = p.GetInt("page_size") ?? p.GetInt("pageSize") ?? 50;
int pageNumber = p.GetInt("page_number") ?? p.GetInt("pageNumber") ?? 1;
scope = AssetPathUtility.SanitizeAssetPath(scope);
if (scope == null)
{
return new ErrorResponse("Invalid path: contains traversal sequences.");
}
string[] folderScope = AssetDatabase.IsValidFolder(scope)
? new[] { scope }
: null;
// Find UXML and USS assets based on filter
var allAssets = new List<object>();
bool includeUxml = string.IsNullOrEmpty(filterType) ||
filterType.Equals("uxml", StringComparison.OrdinalIgnoreCase) ||
filterType.Equals("VisualTreeAsset", StringComparison.OrdinalIgnoreCase);
bool includeUss = string.IsNullOrEmpty(filterType) ||
filterType.Equals("uss", StringComparison.OrdinalIgnoreCase) ||
filterType.Equals("StyleSheet", StringComparison.OrdinalIgnoreCase);
bool includePanelSettings = string.IsNullOrEmpty(filterType) ||
filterType.Equals("PanelSettings", StringComparison.OrdinalIgnoreCase);
if (includeUxml)
{
string[] guids = AssetDatabase.FindAssets("t:VisualTreeAsset", folderScope);
foreach (string guid in guids)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
if (!string.IsNullOrEmpty(assetPath))
{
allAssets.Add(new Dictionary<string, object>
{
["path"] = assetPath,
["type"] = "uxml",
["name"] = Path.GetFileName(assetPath),
});
}
}
}
if (includeUss)
{
string[] guids = AssetDatabase.FindAssets("t:StyleSheet", folderScope);
foreach (string guid in guids)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
if (!string.IsNullOrEmpty(assetPath))
{
allAssets.Add(new Dictionary<string, object>
{
["path"] = assetPath,
["type"] = "uss",
["name"] = Path.GetFileName(assetPath),
});
}
}
}
if (includePanelSettings)
{
string[] guids = AssetDatabase.FindAssets("t:PanelSettings", folderScope);
foreach (string guid in guids)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
if (!string.IsNullOrEmpty(assetPath))
{
allAssets.Add(new Dictionary<string, object>
{
["path"] = assetPath,
["type"] = "PanelSettings",
["name"] = Path.GetFileName(assetPath),
});
}
}
}
int total = allAssets.Count;
int startIndex = (pageNumber - 1) * pageSize;
var paged = allAssets.Skip(startIndex).Take(pageSize).ToList();
return new SuccessResponse(
$"Found {total} UI asset(s). Returning page {pageNumber} ({paged.Count} items).",
new
{
total,
pageSize,
pageNumber,
assets = paged,
});
}
// ---- Detach UIDocument ----
private static object DetachUIDocument(JObject @params)
{
var p = new ToolParams(@params);
var targetResult = p.GetRequired("target");
var targetError = targetResult.GetOrError(out string target);
if (targetError != null) return targetError;
var goInstruction = new JObject { ["find"] = target };
GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject;
if (go == null)
{
return new ErrorResponse($"Could not find target GameObject: {target}");
}
var uiDoc = go.GetComponent<UIDocument>();
if (uiDoc == null)
{
return new ErrorResponse($"GameObject '{go.name}' has no UIDocument component.");
}
string sourceAsset = uiDoc.visualTreeAsset != null
? AssetDatabase.GetAssetPath(uiDoc.visualTreeAsset)
: null;
Undo.DestroyObjectImmediate(uiDoc);
EditorUtility.SetDirty(go);
return new SuccessResponse($"Removed UIDocument from {go.name}",
new
{
gameObject = go.name,
removedSourceAsset = sourceAsset,
});
}
// ---- Modify Visual Element ----
private static object ModifyVisualElement(JObject @params)
{
var p = new ToolParams(@params);
var targetResult = p.GetRequired("target");
var targetError = targetResult.GetOrError(out string target);
if (targetError != null) return targetError;
string elementName = p.Get("element_name") ?? p.Get("elementName");
if (string.IsNullOrEmpty(elementName))
{
return new ErrorResponse("'element_name' parameter is required.");
}
var goInstruction = new JObject { ["find"] = target };
GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject;
if (go == null)
{
return new ErrorResponse($"Could not find target GameObject: {target}");
}
var uiDoc = go.GetComponent<UIDocument>();
if (uiDoc == null)
{
return new ErrorResponse($"GameObject '{go.name}' has no UIDocument component.");
}
var root = uiDoc.rootVisualElement;
if (root == null)
{
return new ErrorResponse($"UIDocument on {go.name} has no visual tree (not yet built).");
}
// Find the target element by name
var element = root.Q(elementName);
if (element == null)
{
return new ErrorResponse($"Visual element with name '{elementName}' not found in the visual tree.");
}
var modifications = new List<string>();
// Set text content (Label, Button, etc.)
string text = p.Get("text");
if (text != null && element is TextElement textEl)
{
textEl.text = text;
modifications.Add($"text='{text}'");
}
else if (text != null)
{
return new ErrorResponse($"Element '{elementName}' ({element.GetType().Name}) does not support text content.");
}
// Add CSS classes
JToken addClassesToken = p.GetRaw("add_classes") ?? p.GetRaw("addClasses");
if (addClassesToken is JArray addArr)
{
foreach (var cls in addArr)
{
string className = cls.ToString();
if (!element.ClassListContains(className))
{
element.AddToClassList(className);
modifications.Add($"+class '{className}'");
}
}
}
// Remove CSS classes
JToken removeClassesToken = p.GetRaw("remove_classes") ?? p.GetRaw("removeClasses");
if (removeClassesToken is JArray removeArr)
{
foreach (var cls in removeArr)
{
string className = cls.ToString();
if (element.ClassListContains(className))
{
element.RemoveFromClassList(className);
modifications.Add($"-class '{className}'");
}
}
}
// Toggle CSS classes
JToken toggleClassesToken = p.GetRaw("toggle_classes") ?? p.GetRaw("toggleClasses");
if (toggleClassesToken is JArray toggleArr)
{
foreach (var cls in toggleArr)
{
string className = cls.ToString();
element.ToggleInClassList(className);
modifications.Add($"~class '{className}'");
}
}
// Set inline styles
JToken styleToken = p.GetRaw("style") ?? p.GetRaw("inline_style") ?? p.GetRaw("inlineStyle");
if (styleToken is JObject styleObj)
{
ApplyInlineStyles(element, styleObj, modifications);
}
// Set enabled/disabled
bool? enabled = p.GetNullableBool("enabled");
if (enabled.HasValue)
{
element.SetEnabled(enabled.Value);
modifications.Add($"enabled={enabled.Value}");
}
// Set visibility
string visibility = p.Get("visible");
if (visibility != null)
{
bool isVisible = visibility.Equals("true", StringComparison.OrdinalIgnoreCase) ||
visibility == "1";
element.style.display = isVisible ? DisplayStyle.Flex : DisplayStyle.None;
modifications.Add($"visible={isVisible}");
}
// Set tooltip
string tooltip = p.Get("tooltip");
if (tooltip != null)
{
element.tooltip = tooltip;
modifications.Add($"tooltip='{tooltip}'");
}
// Filter out [skipped] entries so they don't count as real modifications
var applied = modifications.Where(m => !m.StartsWith("[skipped]")).ToList();
var skipped = modifications.Where(m => m.StartsWith("[skipped]")).ToList();
if (applied.Count == 0)
{
string msg = skipped.Count > 0
? $"No modifications applied. Skipped unsupported styles: {string.Join(", ", skipped)}"
: "No modifications specified. Provide at least one of: text, add_classes, remove_classes, toggle_classes, style, enabled, visible, tooltip.";
return new ErrorResponse(msg);
}
var responseData = new Dictionary<string, object>
{
{ "gameObject", go.name },
{ "elementName", elementName },
{ "elementType", element.GetType().Name },
{ "modifications", applied },
{ "currentClasses", new List<string>(element.GetClasses()) },
};
if (skipped.Count > 0)
responseData["skipped"] = skipped;
return new SuccessResponse(
$"Modified element '{elementName}' on {go.name}: {string.Join(", ", applied)}",
responseData);
}
private static void ApplyInlineStyles(VisualElement element, JObject styleObj, List<string> modifications)
{
foreach (var prop in styleObj)
{
string key = prop.Key;
JToken val = prop.Value;
switch (key.ToLowerInvariant())
{
case "backgroundcolor":
case "background-color":
if (ColorUtility.TryParseHtmlString(val.ToString(), out Color bgColor))
{
element.style.backgroundColor = bgColor;
modifications.Add($"backgroundColor={val}");
}
break;
case "color":
if (ColorUtility.TryParseHtmlString(val.ToString(), out Color fgColor))
{
element.style.color = fgColor;
modifications.Add($"color={val}");
}
break;
case "fontsize":
case "font-size":
element.style.fontSize = val.ToObject<float>();
modifications.Add($"fontSize={val}");
break;
case "width":
element.style.width = val.ToObject<float>();
modifications.Add($"width={val}");
break;
case "height":
element.style.height = val.ToObject<float>();
modifications.Add($"height={val}");
break;
case "opacity":
element.style.opacity = val.ToObject<float>();
modifications.Add($"opacity={val}");
break;
case "display":
if (Enum.TryParse<DisplayStyle>(val.ToString(), true, out var display))
{
element.style.display = display;
modifications.Add($"display={val}");
}
break;
case "visibility":
if (Enum.TryParse<Visibility>(val.ToString(), true, out var vis))
{
element.style.visibility = vis;
modifications.Add($"visibility={val}");
}
break;
case "flexgrow":
case "flex-grow":
element.style.flexGrow = val.ToObject<float>();
modifications.Add($"flexGrow={val}");
break;
case "flexshrink":
case "flex-shrink":
element.style.flexShrink = val.ToObject<float>();
modifications.Add($"flexShrink={val}");
break;
case "marginleft":
case "margin-left":
element.style.marginLeft = val.ToObject<float>();
modifications.Add($"marginLeft={val}");
break;
case "marginright":
case "margin-right":
element.style.marginRight = val.ToObject<float>();
modifications.Add($"marginRight={val}");
break;
case "margintop":
case "margin-top":
element.style.marginTop = val.ToObject<float>();
modifications.Add($"marginTop={val}");
break;
case "marginbottom":
case "margin-bottom":
element.style.marginBottom = val.ToObject<float>();
modifications.Add($"marginBottom={val}");
break;
case "paddingleft":
case "padding-left":
element.style.paddingLeft = val.ToObject<float>();
modifications.Add($"paddingLeft={val}");
break;
case "paddingright":
case "padding-right":
element.style.paddingRight = val.ToObject<float>();
modifications.Add($"paddingRight={val}");
break;
case "paddingtop":
case "padding-top":
element.style.paddingTop = val.ToObject<float>();
modifications.Add($"paddingTop={val}");
break;
case "paddingbottom":
case "padding-bottom":
element.style.paddingBottom = val.ToObject<float>();
modifications.Add($"paddingBottom={val}");
break;
case "borderradius":
case "border-radius":
float radius = val.ToObject<float>();
element.style.borderTopLeftRadius = radius;
element.style.borderTopRightRadius = radius;
element.style.borderBottomLeftRadius = radius;
element.style.borderBottomRightRadius = radius;
modifications.Add($"borderRadius={val}");
break;
default:
modifications.Add($"[skipped] {key} (unsupported inline style)");
break;
}
}
}
private static bool? GetNullableBool(this ToolParams p, string key)
{
var raw = p.GetRaw(key);
if (raw == null) return null;
if (raw.Type == JTokenType.Boolean) return raw.ToObject<bool>();
string s = raw.ToString();
if (bool.TryParse(s, out bool result)) return result;
return null;
}
/// <summary>
/// Finds the index right after the closing '>' of the root UXML element opening tag.
/// Returns -1 if not found or if the root tag is self-closing.
/// </summary>
private static int FindUxmlBodyStart(string content)
{
int searchFrom = 0;
while (true)
{
int idx = content.IndexOf("<ui:UXML", searchFrom, StringComparison.OrdinalIgnoreCase);
if (idx < 0)
idx = content.IndexOf("<UXML", searchFrom, StringComparison.OrdinalIgnoreCase);
if (idx < 0)
return -1;
// Skip matches inside XML comments (<!-- ... -->)
int commentStart = content.LastIndexOf("<!--", idx, StringComparison.Ordinal);
if (commentStart >= 0)
{
int commentEnd = content.IndexOf("-->", commentStart + 4, StringComparison.Ordinal);
if (commentEnd >= 0 && commentEnd + 3 > idx)
{
searchFrom = commentEnd + 3;
continue;
}
}
int closeTag = content.IndexOf('>', idx);
if (closeTag < 0) return -1;
// Self-closing tag cannot have children
if (closeTag > 0 && content[closeTag - 1] == '/') return -1;
return closeTag + 1;
}
}
// ---- Helpers ----
private static string EnsureUniqueFilePath(string path)
{
if (!File.Exists(path)) return path;
string dir = Path.GetDirectoryName(path) ?? string.Empty;
string baseName = Path.GetFileNameWithoutExtension(path);
string ext = Path.GetExtension(path);
int counter = 1;
string candidate;
do
{
candidate = Path.Combine(dir, $"{baseName}-{counter}{ext}").Replace('\\', '/');
counter++;
} while (File.Exists(candidate));
return candidate;
}
private static string ColorToHex(Color c)
{
return $"#{ColorUtility.ToHtmlStringRGBA(c)}";
}
private static string GetDecodedContents(ToolParams p)
{
bool isEncoded = p.GetBool("contents_encoded") || p.GetBool("contentsEncoded");
if (isEncoded)
{
string encoded = p.Get("encoded_contents") ?? p.Get("encodedContents");
if (!string.IsNullOrEmpty(encoded))
{
try
{
return Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
}
catch (FormatException ex)
{
throw new ArgumentException(
"Parameter 'encodedContents' must be valid base64 when 'contentsEncoded' is true.",
ex);
}
}
}
return p.Get("contents");
}
/// <summary>
/// Validates UXML content before writing to disk.
/// Returns null if valid, or an error message if malformed.
/// Populates warnings list with non-fatal issues.
/// Uses XmlParserContext to pre-declare common UXML namespace prefixes
/// (ui, uie, engine, editor) since Unity's parser is more lenient than System.Xml.
/// </summary>
/// <summary>
/// Ensures the root UXML element has editor-extension-mode attribute.
/// UI Builder requires this to open the file. Injects "False" if missing.
/// </summary>
private static string EnsureEditorExtensionMode(string contents)
{
if (contents.Contains("editor-extension-mode"))
return contents;
int idx = contents.IndexOf("<ui:UXML", StringComparison.Ordinal);
if (idx < 0)
idx = contents.IndexOf("<UXML", StringComparison.Ordinal);
if (idx < 0)
return contents;
int closeTag = contents.IndexOf('>', idx);
if (closeTag < 0)
return contents;
bool selfClosing = contents[closeTag - 1] == '/';
int insertPos = selfClosing ? closeTag - 1 : closeTag;
return contents.Substring(0, insertPos)
+ " editor-extension-mode=\"False\""
+ contents.Substring(insertPos);
}
private static string ValidateUxmlContent(string contents, List<string> warnings)
{
if (string.IsNullOrWhiteSpace(contents))
return "UXML content is empty.";
var nt = new NameTable();
var nsMgr = new XmlNamespaceManager(nt);
nsMgr.AddNamespace("ui", "UnityEngine.UIElements");
nsMgr.AddNamespace("uie", "UnityEditor.UIElements");
nsMgr.AddNamespace("engine", "UnityEngine.UIElements");
nsMgr.AddNamespace("editor", "UnityEditor.UIElements");
var ctx = new XmlParserContext(nt, nsMgr, null, XmlSpace.Default);
string rootLocalName = null;
try
{
using (var reader = XmlReader.Create(new StringReader(contents), null, ctx))
{
while (reader.Read())
{
if (reader.NodeType == XmlNodeType.Element && rootLocalName == null)
rootLocalName = reader.LocalName;
}
}
}
catch (XmlException ex)
{
return $"Malformed XML at line {ex.LineNumber}, position {ex.LinePosition}: {ex.Message}";
}
if (rootLocalName == null)
return "UXML content has no root element.";
if (rootLocalName != "UXML")
warnings.Add($"Root element is <{rootLocalName}>, expected <UXML> or <ui:UXML>.");
if (!contents.Contains("UnityEngine.UIElements"))
{
warnings.Add("Missing namespace declaration xmlns:ui=\"UnityEngine.UIElements\". " +
"UI Builder may fail to open this file.");
}
return null;
}
/// <summary>
/// Validates a UXML asset after import by attempting to load it as a VisualTreeAsset.
/// </summary>
private static void ValidateUxmlPostImport(string assetPath, List<string> warnings)
{
var asset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(assetPath);
if (asset == null)
{
warnings.Add("Unity failed to parse the UXML file. " +
"The file was written but UI Builder will not be able to open it. " +
"Check the console for details.");
}
}
}
}