coplaydev--unity-mcp
1913 行
77 KiB
C#
1913 行
77 KiB
C#
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.");
|
|
}
|
|
}
|
|
}
|
|
}
|