@mflrevan/ucp 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bridge/com.ucp.bridge/CHANGELOG.md +48 -0
- package/bridge/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +55 -6
- package/bridge/com.ucp.bridge/Editor/Bridge/EditorStateSummary.cs +165 -0
- package/bridge/com.ucp.bridge/Editor/Bridge/EditorStateSummary.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Compatibility/UnityObjectCompat.cs +22 -5
- package/bridge/com.ucp.bridge/Editor/Controllers/EditorController.cs +1 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/HierarchyController.cs +10 -10
- package/bridge/com.ucp.bridge/Editor/Controllers/LogsController.cs +71 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/MaterialController.cs +2 -2
- package/bridge/com.ucp.bridge/Editor/Controllers/ObjectLocator.cs +4 -4
- package/bridge/com.ucp.bridge/Editor/Controllers/ObjectReferenceResolver.cs +3 -3
- package/bridge/com.ucp.bridge/Editor/Controllers/PrefabController.cs +6 -6
- package/bridge/com.ucp.bridge/Editor/Controllers/PropertyController.cs +7 -7
- package/bridge/com.ucp.bridge/Editor/Controllers/RecordingController.cs +8 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/SceneChangeTracker.cs +3 -3
- package/bridge/com.ucp.bridge/Editor/Controllers/SceneController.cs +3 -3
- package/bridge/com.ucp.bridge/Editor/Controllers/SnapshotController.cs +5 -5
- package/bridge/com.ucp.bridge/Editor/Controllers/TestRunnerController.cs +1 -1
- package/bridge/com.ucp.bridge/Editor/Controllers/TransformController.cs +1 -1
- package/bridge/com.ucp.bridge/Editor/Controllers/UiController.cs +70 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/UiController.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/ViewController.cs +1 -1
- package/bridge/com.ucp.bridge/Editor/Protocol/MiniJson.cs +1 -1
- package/bridge/com.ucp.bridge/Editor/Ui/UiHost.cs +468 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiHost.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiLintService.cs +554 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiLintService.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiOperationManager.cs +870 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiOperationManager.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs +496 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs +854 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs +374 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs +690 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs.meta +11 -0
- package/bridge/com.ucp.bridge/{Runtime.meta → Editor/Ui.meta} +1 -1
- package/bridge/com.ucp.bridge/Tests/Editor/ControllerSmokeTests.cs +79 -4
- package/bridge/com.ucp.bridge/Tests/Editor/SpatialVisualControllerTests.cs +2 -2
- package/bridge/com.ucp.bridge/Tests/Editor/UiControllerTests.cs +547 -0
- package/bridge/com.ucp.bridge/Tests/Editor/UiControllerTests.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs +253 -0
- package/bridge/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs +587 -0
- package/bridge/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs.meta +11 -0
- package/bridge/com.ucp.bridge/package.json +1 -1
- package/package.json +1 -1
- package/bridge/com.ucp.bridge/Runtime/UCP.Bridge.Runtime.asmdef +0 -14
- package/bridge/com.ucp.bridge/Runtime/UCP.Bridge.Runtime.asmdef.meta +0 -7
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
#if UNITY_6000_0_OR_NEWER
|
|
2
|
+
using System;
|
|
3
|
+
using System.Collections;
|
|
4
|
+
using System.Collections.Generic;
|
|
5
|
+
using System.Linq;
|
|
6
|
+
using System.Reflection;
|
|
7
|
+
using UnityEngine;
|
|
8
|
+
using UnityEngine.UIElements;
|
|
9
|
+
|
|
10
|
+
namespace UCP.Bridge
|
|
11
|
+
{
|
|
12
|
+
internal sealed class UiInspectOptions
|
|
13
|
+
{
|
|
14
|
+
private const int DefaultDepth = 12;
|
|
15
|
+
private const int DefaultMaxElements = 500;
|
|
16
|
+
|
|
17
|
+
internal string Query { get; private set; }
|
|
18
|
+
internal int MaxDepth { get; private set; }
|
|
19
|
+
internal int MaxElements { get; private set; }
|
|
20
|
+
internal string Detail { get; private set; }
|
|
21
|
+
|
|
22
|
+
internal static UiInspectOptions Parse(Dictionary<string, object> parameters)
|
|
23
|
+
{
|
|
24
|
+
parameters ??= new Dictionary<string, object>();
|
|
25
|
+
var options = new UiInspectOptions
|
|
26
|
+
{
|
|
27
|
+
Query = ReadString(parameters, "query"),
|
|
28
|
+
MaxDepth = ReadInt(parameters, "depth", DefaultDepth, 0, 64),
|
|
29
|
+
MaxElements = ReadInt(parameters, "max", DefaultMaxElements, 1, 5000),
|
|
30
|
+
Detail = ReadString(parameters, "detail") ?? "normal"
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
options.Detail = options.Detail.ToLowerInvariant();
|
|
34
|
+
if (options.Detail != "summary" && options.Detail != "normal" && options.Detail != "verbose")
|
|
35
|
+
throw new ArgumentException("'detail' must be one of: summary, normal, verbose");
|
|
36
|
+
|
|
37
|
+
UiSimpleQuery.Validate(options.Query);
|
|
38
|
+
return options;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private static string ReadString(Dictionary<string, object> parameters, string key)
|
|
42
|
+
{
|
|
43
|
+
return parameters.TryGetValue(key, out var value) && value != null
|
|
44
|
+
? value.ToString()
|
|
45
|
+
: null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private static int ReadInt(
|
|
49
|
+
Dictionary<string, object> parameters,
|
|
50
|
+
string key,
|
|
51
|
+
int defaultValue,
|
|
52
|
+
int min,
|
|
53
|
+
int max)
|
|
54
|
+
{
|
|
55
|
+
if (!parameters.TryGetValue(key, out var value) || value == null)
|
|
56
|
+
return defaultValue;
|
|
57
|
+
|
|
58
|
+
try
|
|
59
|
+
{
|
|
60
|
+
var result = Convert.ToInt32(value);
|
|
61
|
+
if (result < min || result > max)
|
|
62
|
+
throw new ArgumentException($"'{key}' must be between {min} and {max}");
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
catch (ArgumentException)
|
|
66
|
+
{
|
|
67
|
+
throw;
|
|
68
|
+
}
|
|
69
|
+
catch (Exception)
|
|
70
|
+
{
|
|
71
|
+
throw new ArgumentException($"'{key}' must be an integer between {min} and {max}");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
internal static class UiVisualTreeInspector
|
|
77
|
+
{
|
|
78
|
+
internal static Dictionary<string, object> Inspect(
|
|
79
|
+
VisualElement root,
|
|
80
|
+
UiInspectOptions options,
|
|
81
|
+
IReadOnlyList<UiCollectionMetadata> collectionMetadata)
|
|
82
|
+
{
|
|
83
|
+
if (root == null)
|
|
84
|
+
throw new ArgumentException("The resolved UXML did not create a visual tree");
|
|
85
|
+
|
|
86
|
+
var selectedRoots = Select(root, options.Query);
|
|
87
|
+
var context = new SnapshotContext(options);
|
|
88
|
+
var roots = new List<object>();
|
|
89
|
+
foreach (var selectedRoot in selectedRoots)
|
|
90
|
+
{
|
|
91
|
+
if (context.Truncated)
|
|
92
|
+
break;
|
|
93
|
+
var node = BuildNode(selectedRoot, 0, context);
|
|
94
|
+
if (node != null)
|
|
95
|
+
roots.Add(node);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
var collections = new List<object>();
|
|
99
|
+
if (collectionMetadata != null)
|
|
100
|
+
{
|
|
101
|
+
foreach (var metadata in collectionMetadata)
|
|
102
|
+
collections.Add(metadata.ToDictionary());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return new Dictionary<string, object>
|
|
106
|
+
{
|
|
107
|
+
["query"] = options.Query,
|
|
108
|
+
["matchCount"] = selectedRoots.Count,
|
|
109
|
+
["returnedElementCount"] = context.ReturnedCount,
|
|
110
|
+
["truncated"] = context.Truncated,
|
|
111
|
+
["maxElements"] = options.MaxElements,
|
|
112
|
+
["maxDepth"] = options.MaxDepth,
|
|
113
|
+
["detail"] = options.Detail,
|
|
114
|
+
["roots"] = roots,
|
|
115
|
+
["collections"] = collections
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private static List<VisualElement> Select(VisualElement root, string query)
|
|
120
|
+
{
|
|
121
|
+
if (string.IsNullOrWhiteSpace(query))
|
|
122
|
+
return new List<VisualElement> { root };
|
|
123
|
+
|
|
124
|
+
var result = new List<VisualElement>();
|
|
125
|
+
Visit(root, element =>
|
|
126
|
+
{
|
|
127
|
+
if (UiSimpleQuery.Matches(element, query))
|
|
128
|
+
result.Add(element);
|
|
129
|
+
});
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private static Dictionary<string, object> BuildNode(
|
|
134
|
+
VisualElement element,
|
|
135
|
+
int depth,
|
|
136
|
+
SnapshotContext context)
|
|
137
|
+
{
|
|
138
|
+
if (context.ReturnedCount >= context.Options.MaxElements)
|
|
139
|
+
{
|
|
140
|
+
context.Truncated = true;
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
context.ReturnedCount++;
|
|
145
|
+
var node = new Dictionary<string, object>
|
|
146
|
+
{
|
|
147
|
+
["ref"] = $"e{context.ReturnedCount}",
|
|
148
|
+
["type"] = element.GetType().Name,
|
|
149
|
+
["name"] = string.IsNullOrEmpty(element.name) ? null : element.name,
|
|
150
|
+
["classes"] = element.GetClasses().Cast<object>().ToList()
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (element is TextElement textElement && !string.IsNullOrEmpty(textElement.text))
|
|
154
|
+
node["text"] = textElement.text;
|
|
155
|
+
|
|
156
|
+
var value = ReadValue(element);
|
|
157
|
+
if (value.Found)
|
|
158
|
+
node["value"] = value.Value;
|
|
159
|
+
|
|
160
|
+
if (context.Options.Detail != "summary")
|
|
161
|
+
{
|
|
162
|
+
var displayed = IsDisplayed(element);
|
|
163
|
+
node["enabled"] = element.enabledInHierarchy;
|
|
164
|
+
node["focusable"] = element.focusable;
|
|
165
|
+
node["focused"] = element.panel?.focusController?.focusedElement == element;
|
|
166
|
+
node["visible"] = displayed && element.resolvedStyle.visibility == Visibility.Visible;
|
|
167
|
+
node["layout"] = RectDictionary(element.layout);
|
|
168
|
+
node["worldBound"] = RectDictionary(element.worldBound);
|
|
169
|
+
node["resolvedStyle"] = StyleDictionary(element.resolvedStyle);
|
|
170
|
+
node["bindings"] = BindingDictionaries(element);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (context.Options.Detail == "verbose")
|
|
174
|
+
{
|
|
175
|
+
node["fullType"] = element.GetType().FullName;
|
|
176
|
+
node["pickingMode"] = element.pickingMode.ToString();
|
|
177
|
+
node["tooltip"] = string.IsNullOrEmpty(element.tooltip) ? null : element.tooltip;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
var children = new List<object>();
|
|
181
|
+
if (depth < context.Options.MaxDepth)
|
|
182
|
+
{
|
|
183
|
+
foreach (var child in element.hierarchy.Children())
|
|
184
|
+
{
|
|
185
|
+
if (context.ReturnedCount >= context.Options.MaxElements)
|
|
186
|
+
{
|
|
187
|
+
context.Truncated = true;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
var childNode = BuildNode(child, depth + 1, context);
|
|
192
|
+
if (childNode != null)
|
|
193
|
+
children.Add(childNode);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else if (element.hierarchy.childCount > 0)
|
|
197
|
+
{
|
|
198
|
+
context.Truncated = true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (children.Count > 0)
|
|
202
|
+
node["children"] = children;
|
|
203
|
+
return node;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
internal static List<object> BindingDictionaries(VisualElement element)
|
|
207
|
+
{
|
|
208
|
+
var bindings = new List<object>();
|
|
209
|
+
foreach (var info in element.GetBindingInfos())
|
|
210
|
+
{
|
|
211
|
+
var bindingId = info.bindingId;
|
|
212
|
+
var item = new Dictionary<string, object>
|
|
213
|
+
{
|
|
214
|
+
["property"] = bindingId.ToString(),
|
|
215
|
+
["bindingType"] = info.binding?.GetType().Name
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (element.TryGetLastBindingToUIResult(bindingId, out var toUi))
|
|
219
|
+
{
|
|
220
|
+
item["toUi"] = new Dictionary<string, object>
|
|
221
|
+
{
|
|
222
|
+
["status"] = toUi.status.ToString(),
|
|
223
|
+
["message"] = string.IsNullOrEmpty(toUi.message) ? null : toUi.message
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (element.TryGetLastBindingToSourceResult(bindingId, out var toSource))
|
|
228
|
+
{
|
|
229
|
+
item["toSource"] = new Dictionary<string, object>
|
|
230
|
+
{
|
|
231
|
+
["status"] = toSource.status.ToString(),
|
|
232
|
+
["message"] = string.IsNullOrEmpty(toSource.message) ? null : toSource.message
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
bindings.Add(item);
|
|
237
|
+
}
|
|
238
|
+
return bindings;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private static Dictionary<string, object> RectDictionary(Rect value)
|
|
242
|
+
{
|
|
243
|
+
var finite = UiValue.IsFinite(value.x) && UiValue.IsFinite(value.y) &&
|
|
244
|
+
UiValue.IsFinite(value.width) && UiValue.IsFinite(value.height);
|
|
245
|
+
return new Dictionary<string, object>
|
|
246
|
+
{
|
|
247
|
+
["x"] = UiValue.FiniteOrNull(value.x),
|
|
248
|
+
["y"] = UiValue.FiniteOrNull(value.y),
|
|
249
|
+
["width"] = UiValue.FiniteOrNull(value.width),
|
|
250
|
+
["height"] = UiValue.FiniteOrNull(value.height),
|
|
251
|
+
["finite"] = finite
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private static Dictionary<string, object> StyleDictionary(IResolvedStyle style)
|
|
256
|
+
{
|
|
257
|
+
return new Dictionary<string, object>
|
|
258
|
+
{
|
|
259
|
+
["display"] = style.display.ToString(),
|
|
260
|
+
["visibility"] = style.visibility.ToString(),
|
|
261
|
+
["position"] = style.position.ToString(),
|
|
262
|
+
["flexDirection"] = style.flexDirection.ToString(),
|
|
263
|
+
["justifyContent"] = style.justifyContent.ToString(),
|
|
264
|
+
["alignItems"] = style.alignItems.ToString(),
|
|
265
|
+
["alignSelf"] = style.alignSelf.ToString(),
|
|
266
|
+
["flexGrow"] = UiValue.FiniteOrNull(style.flexGrow),
|
|
267
|
+
["flexShrink"] = UiValue.FiniteOrNull(style.flexShrink),
|
|
268
|
+
["width"] = UiValue.FiniteOrNull(style.width),
|
|
269
|
+
["height"] = UiValue.FiniteOrNull(style.height),
|
|
270
|
+
["opacity"] = UiValue.FiniteOrNull(style.opacity),
|
|
271
|
+
["color"] = ColorDictionary(style.color),
|
|
272
|
+
["backgroundColor"] = ColorDictionary(style.backgroundColor),
|
|
273
|
+
["fontSize"] = UiValue.FiniteOrNull(style.fontSize),
|
|
274
|
+
["whiteSpace"] = style.whiteSpace.ToString()
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private static Dictionary<string, object> ColorDictionary(Color value)
|
|
279
|
+
{
|
|
280
|
+
return new Dictionary<string, object>
|
|
281
|
+
{
|
|
282
|
+
["r"] = UiValue.FiniteOrNull(value.r),
|
|
283
|
+
["g"] = UiValue.FiniteOrNull(value.g),
|
|
284
|
+
["b"] = UiValue.FiniteOrNull(value.b),
|
|
285
|
+
["a"] = UiValue.FiniteOrNull(value.a)
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private static ValueRead ReadValue(VisualElement element)
|
|
290
|
+
{
|
|
291
|
+
try
|
|
292
|
+
{
|
|
293
|
+
var property = element.GetType().GetProperty(
|
|
294
|
+
"value",
|
|
295
|
+
BindingFlags.Instance | BindingFlags.Public);
|
|
296
|
+
if (property == null || !property.CanRead || property.GetIndexParameters().Length != 0)
|
|
297
|
+
return default;
|
|
298
|
+
|
|
299
|
+
return new ValueRead(true, NormalizeValue(property.GetValue(element)));
|
|
300
|
+
}
|
|
301
|
+
catch
|
|
302
|
+
{
|
|
303
|
+
return default;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private static object NormalizeValue(object value)
|
|
308
|
+
{
|
|
309
|
+
if (value == null || value is string || value is bool || value is char ||
|
|
310
|
+
value is byte || value is sbyte || value is short || value is ushort ||
|
|
311
|
+
value is int || value is uint || value is long || value is ulong || value is decimal)
|
|
312
|
+
return value;
|
|
313
|
+
if (value is float floatValue)
|
|
314
|
+
return UiValue.FiniteOrNull(floatValue);
|
|
315
|
+
if (value is double doubleValue)
|
|
316
|
+
return UiValue.FiniteOrNull(doubleValue);
|
|
317
|
+
if (value is Enum enumValue)
|
|
318
|
+
return enumValue.ToString();
|
|
319
|
+
if (value is Color colorValue)
|
|
320
|
+
return ColorDictionary(colorValue);
|
|
321
|
+
if (value is UnityEngine.Object objectValue)
|
|
322
|
+
return objectValue != null ? objectValue.name : null;
|
|
323
|
+
return value.ToString();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private static bool IsDisplayed(VisualElement element)
|
|
327
|
+
{
|
|
328
|
+
for (var current = element; current != null; current = current.parent)
|
|
329
|
+
{
|
|
330
|
+
if (current.resolvedStyle.display == DisplayStyle.None)
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
internal static void Visit(VisualElement root, Action<VisualElement> action)
|
|
337
|
+
{
|
|
338
|
+
if (root == null)
|
|
339
|
+
return;
|
|
340
|
+
action(root);
|
|
341
|
+
// Collection controls can have a null contentContainer while their
|
|
342
|
+
// realized rows still exist in the physical visual hierarchy.
|
|
343
|
+
foreach (var child in root.hierarchy.Children())
|
|
344
|
+
Visit(child, action);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private sealed class SnapshotContext
|
|
348
|
+
{
|
|
349
|
+
internal SnapshotContext(UiInspectOptions options)
|
|
350
|
+
{
|
|
351
|
+
Options = options;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
internal UiInspectOptions Options { get; }
|
|
355
|
+
internal int ReturnedCount { get; set; }
|
|
356
|
+
internal bool Truncated { get; set; }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private readonly struct ValueRead
|
|
360
|
+
{
|
|
361
|
+
internal ValueRead(bool found, object value)
|
|
362
|
+
{
|
|
363
|
+
Found = found;
|
|
364
|
+
Value = value;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
internal bool Found { get; }
|
|
368
|
+
internal object Value { get; }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
internal static class UiSimpleQuery
|
|
373
|
+
{
|
|
374
|
+
internal static void Validate(string query)
|
|
375
|
+
{
|
|
376
|
+
if (string.IsNullOrWhiteSpace(query))
|
|
377
|
+
return;
|
|
378
|
+
query = query.Trim();
|
|
379
|
+
if (query == "#" || query == ".")
|
|
380
|
+
throw new ArgumentException("'query' must contain a name, class, or element type");
|
|
381
|
+
if (query.IndexOfAny(new[] { ' ', '>', '+', '~', '[', ']', ':', ',' }) >= 0)
|
|
382
|
+
{
|
|
383
|
+
throw new ArgumentException(
|
|
384
|
+
"'query' supports one simple selector only: #name, .class, or element type");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (query[0] != '#' && query[0] != '.')
|
|
388
|
+
return;
|
|
389
|
+
|
|
390
|
+
// A second '#' or '.' would be read as part of the name and match nothing.
|
|
391
|
+
for (var i = 1; i < query.Length; i++)
|
|
392
|
+
{
|
|
393
|
+
var c = query[i];
|
|
394
|
+
if (!(char.IsLetterOrDigit(c) || c == '_' || c == '-'))
|
|
395
|
+
{
|
|
396
|
+
throw new ArgumentException(
|
|
397
|
+
"'query' #name and .class selectors may contain only letters, digits, '_' and '-'; " +
|
|
398
|
+
"compound selectors such as '.a.b' are not supported");
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
internal static bool Matches(VisualElement element, string query)
|
|
404
|
+
{
|
|
405
|
+
if (element == null || string.IsNullOrWhiteSpace(query))
|
|
406
|
+
return false;
|
|
407
|
+
query = query.Trim();
|
|
408
|
+
if (query[0] == '#')
|
|
409
|
+
return string.Equals(element.name, query.Substring(1), StringComparison.Ordinal);
|
|
410
|
+
if (query[0] == '.')
|
|
411
|
+
return element.ClassListContains(query.Substring(1));
|
|
412
|
+
return string.Equals(element.GetType().Name, query, StringComparison.OrdinalIgnoreCase) ||
|
|
413
|
+
string.Equals(element.GetType().FullName, query, StringComparison.OrdinalIgnoreCase);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
internal static class UiAudit
|
|
418
|
+
{
|
|
419
|
+
private const int MaxDiagnostics = 200;
|
|
420
|
+
|
|
421
|
+
internal static Dictionary<string, object> Run(
|
|
422
|
+
VisualElement root,
|
|
423
|
+
UiApplyReport applyReport,
|
|
424
|
+
IReadOnlyList<UiCollectionMetadata> collectionMetadata)
|
|
425
|
+
{
|
|
426
|
+
var diagnostics = new DiagnosticCollector();
|
|
427
|
+
var collectionRoots = FindCollectionRoots(root, collectionMetadata);
|
|
428
|
+
var namedElements = new Dictionary<string, List<VisualElement>>(StringComparer.Ordinal);
|
|
429
|
+
|
|
430
|
+
if (applyReport != null)
|
|
431
|
+
{
|
|
432
|
+
foreach (var diagnostic in applyReport.Diagnostics)
|
|
433
|
+
{
|
|
434
|
+
var severity = diagnostic.TryGetValue("severity", out var value)
|
|
435
|
+
? value?.ToString()
|
|
436
|
+
: null;
|
|
437
|
+
diagnostics.Add(severity == "error", diagnostic);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
UiVisualTreeInspector.Visit(root, element =>
|
|
442
|
+
{
|
|
443
|
+
var insideDynamicCollection = HasAncestor(element, collectionRoots);
|
|
444
|
+
if (!insideDynamicCollection && IsUserElementName(element.name))
|
|
445
|
+
{
|
|
446
|
+
if (!namedElements.TryGetValue(element.name, out var matches))
|
|
447
|
+
{
|
|
448
|
+
matches = new List<VisualElement>();
|
|
449
|
+
namedElements.Add(element.name, matches);
|
|
450
|
+
}
|
|
451
|
+
matches.Add(element);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (element.focusable && !IsUnityInternalName(element.name) && IsVisibleThroughAncestors(element))
|
|
455
|
+
{
|
|
456
|
+
var rect = element.worldBound;
|
|
457
|
+
if (UiValue.IsFinite(rect.width) && UiValue.IsFinite(rect.height) &&
|
|
458
|
+
(rect.width <= 0f || rect.height <= 0f))
|
|
459
|
+
{
|
|
460
|
+
diagnostics.Add(false, Diagnostic(
|
|
461
|
+
"focusable_zero_size",
|
|
462
|
+
"Focusable element has zero rendered width or height",
|
|
463
|
+
element));
|
|
464
|
+
}
|
|
465
|
+
else if (!insideDynamicCollection && !HasScrollViewAncestor(element) &&
|
|
466
|
+
IsFinitePositive(rect) && IsFinitePositive(root.worldBound))
|
|
467
|
+
{
|
|
468
|
+
var viewport = root.worldBound;
|
|
469
|
+
var intersection = Intersect(rect, viewport);
|
|
470
|
+
if (intersection.width <= 0f || intersection.height <= 0f)
|
|
471
|
+
{
|
|
472
|
+
diagnostics.Add(false, Diagnostic(
|
|
473
|
+
"focusable_outside_viewport",
|
|
474
|
+
"Focusable element is fully outside the UI viewport",
|
|
475
|
+
element));
|
|
476
|
+
}
|
|
477
|
+
else if (intersection.width + 0.5f < rect.width ||
|
|
478
|
+
intersection.height + 0.5f < rect.height)
|
|
479
|
+
{
|
|
480
|
+
diagnostics.Add(false, Diagnostic(
|
|
481
|
+
"focusable_partially_outside_viewport",
|
|
482
|
+
"Focusable element is partially outside the UI viewport",
|
|
483
|
+
element));
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
foreach (var bindingObject in UiVisualTreeInspector.BindingDictionaries(element))
|
|
489
|
+
{
|
|
490
|
+
if (bindingObject is not Dictionary<string, object> binding)
|
|
491
|
+
continue;
|
|
492
|
+
CheckBindingResult(
|
|
493
|
+
binding,
|
|
494
|
+
"toUi",
|
|
495
|
+
element,
|
|
496
|
+
diagnostics);
|
|
497
|
+
CheckBindingResult(
|
|
498
|
+
binding,
|
|
499
|
+
"toSource",
|
|
500
|
+
element,
|
|
501
|
+
diagnostics);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
foreach (var pair in namedElements)
|
|
506
|
+
{
|
|
507
|
+
if (pair.Value.Count < 2)
|
|
508
|
+
continue;
|
|
509
|
+
diagnostics.Add(false, new Dictionary<string, object>
|
|
510
|
+
{
|
|
511
|
+
["code"] = "duplicate_name",
|
|
512
|
+
["message"] = $"Element name '{pair.Key}' is used {pair.Value.Count} times outside dynamic collections",
|
|
513
|
+
["element"] = new Dictionary<string, object>
|
|
514
|
+
{
|
|
515
|
+
["type"] = pair.Value[0].GetType().Name,
|
|
516
|
+
["name"] = pair.Key
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return new Dictionary<string, object>
|
|
522
|
+
{
|
|
523
|
+
["passed"] = diagnostics.ErrorCount == 0,
|
|
524
|
+
["errorCount"] = diagnostics.ErrorCount,
|
|
525
|
+
["warningCount"] = diagnostics.WarningCount,
|
|
526
|
+
["diagnosticsTruncated"] = diagnostics.Truncated,
|
|
527
|
+
["errors"] = diagnostics.Errors,
|
|
528
|
+
["warnings"] = diagnostics.Warnings
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private static void CheckBindingResult(
|
|
533
|
+
Dictionary<string, object> binding,
|
|
534
|
+
string direction,
|
|
535
|
+
VisualElement element,
|
|
536
|
+
DiagnosticCollector diagnostics)
|
|
537
|
+
{
|
|
538
|
+
if (!binding.TryGetValue(direction, out var resultObject) ||
|
|
539
|
+
resultObject is not Dictionary<string, object> result ||
|
|
540
|
+
!result.TryGetValue("status", out var statusObject))
|
|
541
|
+
return;
|
|
542
|
+
|
|
543
|
+
var status = statusObject?.ToString();
|
|
544
|
+
if (string.Equals(status, "Success", StringComparison.OrdinalIgnoreCase))
|
|
545
|
+
return;
|
|
546
|
+
|
|
547
|
+
var message = result.TryGetValue("message", out var messageObject)
|
|
548
|
+
? messageObject?.ToString()
|
|
549
|
+
: null;
|
|
550
|
+
var property = binding.TryGetValue("property", out var propertyObject)
|
|
551
|
+
? propertyObject?.ToString()
|
|
552
|
+
: null;
|
|
553
|
+
var diagnostic = Diagnostic(
|
|
554
|
+
"binding_" + (status ?? "unknown").ToLowerInvariant(),
|
|
555
|
+
$"Binding {direction} for '{property}' reported {status}: {message}",
|
|
556
|
+
element);
|
|
557
|
+
diagnostics.Add(string.Equals(status, "Failure", StringComparison.OrdinalIgnoreCase), diagnostic);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private static Dictionary<string, object> Diagnostic(
|
|
561
|
+
string code,
|
|
562
|
+
string message,
|
|
563
|
+
VisualElement element)
|
|
564
|
+
{
|
|
565
|
+
return new Dictionary<string, object>
|
|
566
|
+
{
|
|
567
|
+
["code"] = code,
|
|
568
|
+
["message"] = message,
|
|
569
|
+
["element"] = new Dictionary<string, object>
|
|
570
|
+
{
|
|
571
|
+
["type"] = element.GetType().Name,
|
|
572
|
+
["name"] = string.IsNullOrEmpty(element.name) ? null : element.name
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private static HashSet<VisualElement> FindCollectionRoots(
|
|
578
|
+
VisualElement root,
|
|
579
|
+
IReadOnlyList<UiCollectionMetadata> metadata)
|
|
580
|
+
{
|
|
581
|
+
var result = new HashSet<VisualElement>();
|
|
582
|
+
if (metadata == null)
|
|
583
|
+
return result;
|
|
584
|
+
foreach (var collection in metadata)
|
|
585
|
+
{
|
|
586
|
+
if (collection.Selector == ":root")
|
|
587
|
+
{
|
|
588
|
+
result.Add(root);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
UiVisualTreeInspector.Visit(root, element =>
|
|
592
|
+
{
|
|
593
|
+
if (UiSimpleQuery.Matches(element, collection.Selector))
|
|
594
|
+
result.Add(element);
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
return result;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private static bool HasAncestor(VisualElement element, HashSet<VisualElement> candidates)
|
|
601
|
+
{
|
|
602
|
+
for (var current = element; current != null; current = current.parent)
|
|
603
|
+
{
|
|
604
|
+
if (candidates.Contains(current))
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private static bool HasScrollViewAncestor(VisualElement element)
|
|
611
|
+
{
|
|
612
|
+
for (var current = element.parent; current != null; current = current.parent)
|
|
613
|
+
{
|
|
614
|
+
if (current is ScrollView)
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private static bool IsUserElementName(string name)
|
|
621
|
+
{
|
|
622
|
+
return !string.IsNullOrEmpty(name) &&
|
|
623
|
+
!IsUnityInternalName(name);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private static bool IsUnityInternalName(string name)
|
|
627
|
+
{
|
|
628
|
+
return name != null && name.StartsWith("unity-", StringComparison.Ordinal);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private static bool IsVisibleThroughAncestors(VisualElement element)
|
|
632
|
+
{
|
|
633
|
+
for (var current = element; current != null; current = current.parent)
|
|
634
|
+
{
|
|
635
|
+
if (current.resolvedStyle.display == DisplayStyle.None ||
|
|
636
|
+
current.resolvedStyle.visibility == Visibility.Hidden)
|
|
637
|
+
{
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private static bool IsFinitePositive(Rect rect)
|
|
645
|
+
{
|
|
646
|
+
return UiValue.IsFinite(rect.x) && UiValue.IsFinite(rect.y) &&
|
|
647
|
+
UiValue.IsFinite(rect.width) && UiValue.IsFinite(rect.height) &&
|
|
648
|
+
rect.width > 0f && rect.height > 0f;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private static Rect Intersect(Rect left, Rect right)
|
|
652
|
+
{
|
|
653
|
+
var xMin = Mathf.Max(left.xMin, right.xMin);
|
|
654
|
+
var yMin = Mathf.Max(left.yMin, right.yMin);
|
|
655
|
+
var xMax = Mathf.Min(left.xMax, right.xMax);
|
|
656
|
+
var yMax = Mathf.Min(left.yMax, right.yMax);
|
|
657
|
+
return Rect.MinMaxRect(xMin, yMin, Mathf.Max(xMin, xMax), Mathf.Max(yMin, yMax));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private sealed class DiagnosticCollector
|
|
661
|
+
{
|
|
662
|
+
internal List<object> Errors { get; } = new List<object>();
|
|
663
|
+
internal List<object> Warnings { get; } = new List<object>();
|
|
664
|
+
internal int ErrorCount { get; private set; }
|
|
665
|
+
internal int WarningCount { get; private set; }
|
|
666
|
+
internal bool Truncated { get; private set; }
|
|
667
|
+
|
|
668
|
+
internal void Add(bool isError, object diagnostic)
|
|
669
|
+
{
|
|
670
|
+
// Bound the response, but keep counting every diagnostic so
|
|
671
|
+
// truncation cannot hide a failing audit or warning policy.
|
|
672
|
+
if (isError)
|
|
673
|
+
ErrorCount++;
|
|
674
|
+
else
|
|
675
|
+
WarningCount++;
|
|
676
|
+
|
|
677
|
+
if (Errors.Count + Warnings.Count >= MaxDiagnostics)
|
|
678
|
+
{
|
|
679
|
+
Truncated = true;
|
|
680
|
+
if (!isError || Warnings.Count == 0)
|
|
681
|
+
return;
|
|
682
|
+
// Preserve the cause of failure even when warnings arrived first.
|
|
683
|
+
Warnings.RemoveAt(Warnings.Count - 1);
|
|
684
|
+
}
|
|
685
|
+
(isError ? Errors : Warnings).Add(diagnostic);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
#endif
|