diff --git a/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef b/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef index 6963c78b6d..457d9307bb 100644 --- a/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef +++ b/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef @@ -17,6 +17,7 @@ "GUID:f1a38fcd7e85047c5a86331a3e37ddfc", "GUID:45d3617c96fa64d3e95383450e67f25a", "GUID:a2d87883023de4cf59b7d1962d5dd8aa", + "GUID:569bba870e5e64d0d94144057b927001", "GUID:527f26a36b5043c2bd4d4036d04cd76d", "GUID:94d8abc693f543a691a4645a5ff42e5c", "GUID:aa7cf56cc5f074e57ba35272b877e116", diff --git a/Assets/Tests/PlayMode/UnityCLILoop.Tests.PlayMode.asmdef b/Assets/Tests/PlayMode/UnityCLILoop.Tests.PlayMode.asmdef index f3c297c562..2ed54bbd2e 100644 --- a/Assets/Tests/PlayMode/UnityCLILoop.Tests.PlayMode.asmdef +++ b/Assets/Tests/PlayMode/UnityCLILoop.Tests.PlayMode.asmdef @@ -14,6 +14,7 @@ "GUID:45d3617c96fa64d3e95383450e67f25a", "GUID:527f26a36b5043c2bd4d4036d04cd76d", "GUID:a2d87883023de4cf59b7d1962d5dd8aa", + "GUID:569bba870e5e64d0d94144057b927001", "GUID:aa7cf56cc5f074e57ba35272b877e116", "GUID:dc412b2cd951d452b8dcec99c3adeb19", "GUID:3b7aa330d87dc452293296dd5830859b", diff --git a/Packages/src/Editor/FirstPartyTools/Common/MouseUi/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/Common/MouseUi/AssemblyInfo.cs index 3cce180a5f..2d7abfd1a7 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/MouseUi/AssemblyInfo.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/MouseUi/AssemblyInfo.cs @@ -2,6 +2,7 @@ [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Common.InputRecording.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Common.UIToolkit.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.ReplayInput.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Screenshot.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor")] diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit.meta b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit.meta new file mode 100644 index 0000000000..93e15e8c20 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0762c3778edd0468ca0bd7dd96a36017 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/AssemblyInfo.cs new file mode 100644 index 0000000000..6f9143d26e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; + +// Only the tests see these internals: tools use this module through its entry point alone, and the +// compiler keeps them away from the parts behind it. +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.PlayMode")] diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/AssemblyInfo.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/AssemblyInfo.cs.meta new file mode 100644 index 0000000000..fdfcb15522 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e4f778f9f51ce401bb91136ac63086a4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitControlCollector.cs b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitControlCollector.cs new file mode 100644 index 0000000000..61d742d76b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitControlCollector.cs @@ -0,0 +1,141 @@ +#nullable enable +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UIElements; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Walks the documents of the gathered panels and lists their reachable controls in top-left Game View + /// pixels, skipping controls that pointer input could not reach. + /// + internal static class UIToolkitControlCollector + { + // Expects the panels' layouts to be settled already, because bounds and picking read them. + internal static List Collect( + List panels, + Func> probePointsForTopLeftBounds) + { + List controls = new List(); + foreach (UIToolkitScreenPanels.PanelEntry entry in panels) + { + if (entry.Mapping == null) + { + continue; + } + + VisitContext context = new VisitContext( + entry, + entry.Mapping, + UIToolkitScreenPanels.CreateFrontPickers(panels, entry), + point => UIToolkitScreenPanels.PickInPanel(entry.Panel, point), + probePointsForTopLeftBounds, + controls); + foreach (UIDocument document in entry.Documents) + { + Visit(document.rootVisualElement, document, context); + } + } + + return controls; + } + + private static void Visit(VisualElement element, UIDocument document, VisitContext context) + { + // A child can still resolve to Flex under a display:none parent, so the whole subtree stops here. + if (element.resolvedStyle.display == DisplayStyle.None) + { + return; + } + + string? type = UIToolkitElementClassifier.Classify(element); + if (type != null) + { + TryAdd(element, type, document, context); + + // The parts inside a control (the slider and buttons of a Scroller, for example) are not + // separate controls. + return; + } + + // hierarchy, not Children(): Children() lists only the content container, which hides a + // ScrollView's scrollers. + for (int index = 0; index < element.hierarchy.childCount; index++) + { + Visit(element.hierarchy[index], document, context); + } + } + + private static void TryAdd(VisualElement element, string type, UIDocument document, VisitContext context) + { + if (!element.enabledInHierarchy || element.resolvedStyle.visibility == Visibility.Hidden) + { + return; + } + + Rect worldBound = element.worldBound; + if (!IsFiniteNonEmpty(worldBound)) + { + return; + } + + Vector2 topLeftMin = context.Mapping.PanelToScreenTopLeft(new Vector2(worldBound.xMin, worldBound.yMin)); + Vector2 topLeftMax = context.Mapping.PanelToScreenTopLeft(new Vector2(worldBound.xMax, worldBound.yMax)); + Rect topLeftBounds = Rect.MinMaxRect(topLeftMin.x, topLeftMin.y, topLeftMax.x, topLeftMax.y); + + Vector2? reachable = UIToolkitReachabilityProbe.FindReachablePoint( + context.ProbePointsForTopLeftBounds(topLeftBounds), element, context.FrontPickers, context.OwnPicker); + if (reachable == null) + { + return; + } + + VisualElement? parent = element.hierarchy.parent; + context.Controls.Add(new UIToolkitScreenControl( + UIToolkitElementPath.GetDisplayName(element), + UIToolkitElementPath.Build(document, element), + type, + topLeftBounds, + reachable.Value, + Mathf.FloorToInt(context.Entry.SortingOrder), + parent == null ? 0 : parent.hierarchy.IndexOf(element))); + } + + private static bool IsFiniteNonEmpty(Rect rect) + { + return float.IsFinite(rect.x) + && float.IsFinite(rect.y) + && float.IsFinite(rect.width) + && float.IsFinite(rect.height) + && rect.width > 0f + && rect.height > 0f; + } + + private sealed class VisitContext + { + internal VisitContext( + UIToolkitScreenPanels.PanelEntry entry, + UIToolkitScreenMapping mapping, + List> frontPickers, + Func ownPicker, + Func> probePointsForTopLeftBounds, + List controls) + { + Entry = entry; + Mapping = mapping; + FrontPickers = frontPickers; + OwnPicker = ownPicker; + ProbePointsForTopLeftBounds = probePointsForTopLeftBounds; + Controls = controls; + } + + internal UIToolkitScreenPanels.PanelEntry Entry { get; } + internal UIToolkitScreenMapping Mapping { get; } + internal List> FrontPickers { get; } + internal Func OwnPicker { get; } + internal Func> ProbePointsForTopLeftBounds { get; } + internal List Controls { get; } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitControlCollector.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitControlCollector.cs.meta new file mode 100644 index 0000000000..e6e5e54847 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitControlCollector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a6afaba0bcc1a45978ec99510bea2198 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitElementClassifier.cs b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitElementClassifier.cs similarity index 100% rename from Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitElementClassifier.cs rename to Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitElementClassifier.cs diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitElementClassifier.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitElementClassifier.cs.meta similarity index 100% rename from Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitElementClassifier.cs.meta rename to Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitElementClassifier.cs.meta diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitElementPath.cs b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitElementPath.cs new file mode 100644 index 0000000000..2fe140ba06 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitElementPath.cs @@ -0,0 +1,43 @@ +#nullable enable +using System.Collections.Generic; +using UnityEngine.UIElements; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Names UI Toolkit elements the way annotations report them: the owning document's GameObject path, + /// a '#', then the element chain below the document root. + /// + internal static class UIToolkitElementPath + { + private const char PATH_SEPARATOR = '#'; + private const string CHAIN_SEPARATOR = "/"; + + internal static string Build(UIDocument document, VisualElement element) + { + return GameObjectPathUtility.GetFullPath(document.gameObject) + PATH_SEPARATOR + + BuildElementChain(document.rootVisualElement, element); + } + + internal static string GetDisplayName(VisualElement element) + { + return string.IsNullOrEmpty(element.name) ? element.GetType().Name : element.name; + } + + // Lists the elements from just below the document root down to the element, so the root itself, which + // has no name of its own, is left out. + private static string BuildElementChain(VisualElement root, VisualElement element) + { + List segments = new List(); + VisualElement? current = element; + while (current != null && current != root) + { + segments.Add(GetDisplayName(current)); + current = current.hierarchy.parent; + } + + segments.Reverse(); + return string.Join(CHAIN_SEPARATOR, segments); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitElementPath.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitElementPath.cs.meta new file mode 100644 index 0000000000..9153987e20 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitElementPath.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0063bf7119a134d3482034492d38a1b7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitPanelKind.cs b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitPanelKind.cs similarity index 100% rename from Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitPanelKind.cs rename to Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitPanelKind.cs diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitPanelKind.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitPanelKind.cs.meta similarity index 100% rename from Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitPanelKind.cs.meta rename to Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitPanelKind.cs.meta diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitReachabilityProbe.cs b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitReachabilityProbe.cs similarity index 100% rename from Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitReachabilityProbe.cs rename to Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitReachabilityProbe.cs diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitReachabilityProbe.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitReachabilityProbe.cs.meta similarity index 100% rename from Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitReachabilityProbe.cs.meta rename to Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitReachabilityProbe.cs.meta diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenControl.cs b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenControl.cs new file mode 100644 index 0000000000..c8d69dfe20 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenControl.cs @@ -0,0 +1,49 @@ +#nullable enable +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One interactive UI Toolkit control on screen, in top-left Game View pixels. + /// + public sealed class UIToolkitScreenControl + { + public UIToolkitScreenControl( + string name, + string path, + string type, + Rect topLeftBounds, + Vector2 topLeftReachablePoint, + int sortingOrder, + int siblingIndex) + { + Name = name; + Path = path; + Type = type; + TopLeftBounds = topLeftBounds; + TopLeftReachablePoint = topLeftReachablePoint; + SortingOrder = sortingOrder; + SiblingIndex = siblingIndex; + } + + /// The element's name, or its type name when it has none. + public string Name { get; } + + /// The owning document's GameObject path, '#', and the element chain below the document root. + public string Path { get; } + + /// The kind of control, such as "Button", "Toggle" or "Slider". + public string Type { get; } + + public Rect TopLeftBounds { get; } + + /// A point inside the bounds where pointer input reaches this control. + public Vector2 TopLeftReachablePoint { get; } + + /// The panel's sorting order, rounded down. + public int SortingOrder { get; } + + /// The control's index among its parent's children. + public int SiblingIndex { get; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenControl.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenControl.cs.meta new file mode 100644 index 0000000000..6f7cb1efea --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenControl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 986bdff0569264bf395eed0b8b042fe8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenElements.cs b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenElements.cs new file mode 100644 index 0000000000..f3351c5714 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenElements.cs @@ -0,0 +1,36 @@ +#nullable enable +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Entry point for the UI Toolkit controls shown over the Game View on screen-space panels. Every point and + /// rectangle is in Game View pixels with the origin at the top left, the space screenshot annotations and + /// simulated pointer input use. + /// + public static class UIToolkitScreenElements + { + /// + /// Returns the enabled, visible controls that pointer input can reach, each with a point where it does. + /// Controls inside another control (the buttons of a scrollbar, for example) are not listed separately. + /// + /// + /// Gives the candidate points to try for a control's bounds, in the order to try them. The first point + /// that is not covered by a panel in front or by another element becomes the reachable point. + /// + public static IReadOnlyList CollectControls( + Func> probePointsForTopLeftBounds) + { + if (probePointsForTopLeftBounds == null) + { + throw new ArgumentNullException(nameof(probePointsForTopLeftBounds)); + } + + List panels = UIToolkitScreenPanels.Gather(); + UIToolkitScreenPanels.SettleLayouts(panels); + return UIToolkitControlCollector.Collect(panels, probePointsForTopLeftBounds); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenElements.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenElements.cs.meta new file mode 100644 index 0000000000..80e950e025 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenElements.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d2e4ddae68bc647549c46ae552ba5cdf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitScreenMapping.cs b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenMapping.cs similarity index 100% rename from Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitScreenMapping.cs rename to Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenMapping.cs diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitScreenMapping.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenMapping.cs.meta similarity index 100% rename from Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitScreenMapping.cs.meta rename to Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenMapping.cs.meta diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenPanels.cs b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenPanels.cs new file mode 100644 index 0000000000..254ca21787 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenPanels.cs @@ -0,0 +1,137 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using UnityEngine.UIElements; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Finds the screen-space UI Toolkit panels that show in the Game View or take input there, and picks + /// elements on them in top-left Game View pixels. + /// + internal static class UIToolkitScreenPanels + { + internal static List Gather() + { +#if UNITY_6000_4_OR_NEWER + UIDocument[] documents = UnityEngine.Object.FindObjectsByType(); +#else + UIDocument[] documents = UnityEngine.Object.FindObjectsOfType(); +#endif + List panels = new List(); + foreach (UIDocument document in documents) + { + IPanel? panel = GetScreenSpacePanelShownInGameView(document); + if (panel == null) + { + continue; + } + + PanelEntry? entry = panels.Find(candidate => candidate.Panel == panel); + if (entry == null) + { + entry = CreatePanelEntry(panel, document.panelSettings); + panels.Add(entry); + } + + entry.Documents.Add(document); + } + + return panels; + } + + // worldBound and visualTree.layout are only settled once the panel validates its layout, and Pick is + // the only public call that does so. Every panel is settled, including those used only to decide + // what covers what, because PickInPanel reads their layout too. + internal static void SettleLayouts(List panels) + { + foreach (PanelEntry entry in panels) + { + entry.Panel.Pick(Vector2.zero); + } + } + + // Panels with the same sorting order are not treated as covering each other: Unity breaks that tie by + // creation order, which is not public. + internal static List> CreateFrontPickers(List panels, PanelEntry entry) + { + return panels + .Where(other => other.SortingOrder > entry.SortingOrder) + .OrderByDescending(other => other.SortingOrder) + .Select(other => (Func)(point => PickInPanel(other.Panel, point))) + .ToList(); + } + + // Uses the forward screen-to-panel function Unity applies to input, so the result matches where input + // goes even for panels whose function cannot be inverted. + internal static VisualElement? PickInPanel(IPanel panel, Vector2 screenTopLeftPoint) + { + Vector2 panelPoint = RuntimePanelUtils.ScreenToPanel(panel, screenTopLeftPoint); + if (!panel.visualTree.layout.Contains(panelPoint)) + { + return null; + } + + return panel.Pick(panelPoint); + } + + // Returns null for documents that contribute nothing: their panel neither shows in the Game View nor + // takes input there, or they are nested and reached through their parent document's root. + private static IPanel? GetScreenSpacePanelShownInGameView(UIDocument document) + { + if (!document.isActiveAndEnabled || document.parentUI != null) + { + return null; + } + + PanelSettings settings = document.panelSettings; + VisualElement root = document.rootVisualElement; + if (settings == null || root == null || root.panel == null) + { + return null; + } + + if (settings.targetDisplay != Display.activeEditorGameViewTarget) + { + return null; + } + + // World-space panels do not cover overlay controls and their elements have no screen rectangle. + if (UIToolkitPanelKind.IsWorldSpace(settings)) + { + return null; + } + + return root.panel; + } + + // A panel that renders into a texture still takes screen input, so it stays as a cover candidate, but + // its controls are not on the screenshot and get no mapping. + private static PanelEntry CreatePanelEntry(IPanel panel, PanelSettings settings) + { + UIToolkitScreenMapping? mapping = settings.targetTexture == null + ? UIToolkitScreenMapping.TryCreate(screen => RuntimePanelUtils.ScreenToPanel(panel, screen)) + : null; + return new PanelEntry(panel, settings.sortingOrder, mapping); + } + + internal sealed class PanelEntry + { + internal PanelEntry(IPanel panel, float sortingOrder, UIToolkitScreenMapping? mapping) + { + Panel = panel; + SortingOrder = sortingOrder; + Mapping = mapping; + } + + internal IPanel Panel { get; } + internal float SortingOrder { get; } + + // Null when the panel's controls cannot be placed on the screenshot; the panel still covers others. + internal UIToolkitScreenMapping? Mapping { get; } + internal List Documents { get; } = new List(); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenPanels.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenPanels.cs.meta new file mode 100644 index 0000000000..e06d7d0827 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UIToolkitScreenPanels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c1b1722f144d94fef89b4efcd6e14316 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UnityCLILoop.FirstPartyTools.Common.UIToolkit.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UnityCLILoop.FirstPartyTools.Common.UIToolkit.Editor.asmdef new file mode 100644 index 0000000000..525f80e291 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UnityCLILoop.FirstPartyTools.Common.UIToolkit.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "UnityCLILoop.FirstPartyTools.Common.UIToolkit.Editor", + "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", + "references": [ + "GUID:a2d87883023de4cf59b7d1962d5dd8aa" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UnityCLILoop.FirstPartyTools.Common.UIToolkit.Editor.asmdef.meta b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UnityCLILoop.FirstPartyTools.Common.UIToolkit.Editor.asmdef.meta new file mode 100644 index 0000000000..2e5312c8e0 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/UIToolkit/UnityCLILoop.FirstPartyTools.Common.UIToolkit.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 569bba870e5e64d0d94144057b927001 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitElementCollector.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitElementCollector.cs index 86fe5059b9..7df4588027 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitElementCollector.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/UIToolkitAnnotation/UIToolkitElementCollector.cs @@ -1,9 +1,7 @@ #nullable enable -using System; using System.Collections.Generic; using System.Linq; using UnityEngine; -using UnityEngine.UIElements; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -16,309 +14,53 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class UIToolkitElementCollector { - private const char PATH_SEPARATOR = '#'; - private const string CHAIN_SEPARATOR = "/"; - + // gameViewHeight, not Screen.height: uloop commands run outside the player loop, where Screen reports the + // size of the last Editor view drawn. ConvertToSimCoordinates flips the result back with the Game View + // height, so the same height is used here. internal static void Collect(List elements, float gameViewHeight) { - List panels = GatherPanels(); - - // worldBound and visualTree.layout are only settled once the panel validates its layout, and Pick is - // the only public call that does so. Every panel is settled, including those used only to decide - // what covers what, because PickInPanel reads their layout too. - foreach (PanelEntry entry in panels) - { - entry.Panel.Pick(Vector2.zero); - } - - foreach (PanelEntry entry in panels) - { - if (entry.Mapping == null) - { - continue; - } - - VisitContext context = new VisitContext( - gameViewHeight, - entry, - entry.Mapping, - CreateFrontPickers(panels, entry), - point => PickInPanel(entry.Panel, point), - elements); - foreach (UIDocument document in entry.Documents) - { - Visit(document.rootVisualElement, document, context); - } - } - } - - private static List GatherPanels() - { -#if UNITY_6000_4_OR_NEWER - UIDocument[] documents = UnityEngine.Object.FindObjectsByType(); -#else - UIDocument[] documents = UnityEngine.Object.FindObjectsOfType(); -#endif - List panels = new List(); - foreach (UIDocument document in documents) + IReadOnlyList controls = UIToolkitScreenElements.CollectControls( + topLeftBounds => GenerateTopLeftProbePoints(topLeftBounds, gameViewHeight)); + foreach (UIToolkitScreenControl control in controls) { - IPanel? panel = GetScreenSpacePanelShownInGameView(document); - if (panel == null) - { - continue; - } - - PanelEntry? entry = panels.Find(candidate => candidate.Panel == panel); - if (entry == null) + Rect bottomLeftBounds = ToBottomLeft(control.TopLeftBounds, gameViewHeight); + elements.Add(new UIElementInfo { - entry = CreatePanelEntry(panel, document.panelSettings); - panels.Add(entry); - } - - entry.Documents.Add(document); - } - - return panels; - } - - // Returns null for documents that contribute nothing: their panel neither shows in the Game View nor - // takes input there, or they are nested and reached through their parent document's root. - private static IPanel? GetScreenSpacePanelShownInGameView(UIDocument document) - { - if (!document.isActiveAndEnabled || document.parentUI != null) - { - return null; - } - - PanelSettings settings = document.panelSettings; - VisualElement root = document.rootVisualElement; - if (settings == null || root == null || root.panel == null) - { - return null; - } - - if (settings.targetDisplay != Display.activeEditorGameViewTarget) - { - return null; - } - - // World-space panels do not cover overlay controls and their elements have no screen rectangle. - if (UIToolkitPanelKind.IsWorldSpace(settings)) - { - return null; - } - - return root.panel; - } - - // A panel that renders into a texture still takes screen input, so it stays as a cover candidate, but - // its controls are not on the screenshot and get no mapping. - private static PanelEntry CreatePanelEntry(IPanel panel, PanelSettings settings) - { - UIToolkitScreenMapping? mapping = settings.targetTexture == null - ? UIToolkitScreenMapping.TryCreate(screen => RuntimePanelUtils.ScreenToPanel(panel, screen)) - : null; - return new PanelEntry(panel, settings.sortingOrder, mapping); - } - - // Panels with the same sorting order are not treated as covering each other: Unity breaks that tie by - // creation order, which is not public. - private static List> CreateFrontPickers(List panels, PanelEntry entry) - { - return panels - .Where(other => other.SortingOrder > entry.SortingOrder) - .OrderByDescending(other => other.SortingOrder) - .Select(other => (Func)(point => PickInPanel(other.Panel, point))) - .ToList(); - } - - // Uses the forward screen-to-panel function Unity applies to input, so the result matches where input - // goes even for panels whose function cannot be inverted. - private static VisualElement? PickInPanel(IPanel panel, Vector2 screenTopLeftPoint) - { - Vector2 panelPoint = RuntimePanelUtils.ScreenToPanel(panel, screenTopLeftPoint); - if (!panel.visualTree.layout.Contains(panelPoint)) - { - return null; + Name = control.Name, + Path = control.Path, + Type = control.Type, + Interaction = UIElementAnnotationStyling.GetInteractionForType(control.Type), + UiSystem = AnnotatedUiSystem.UIToolkit, + SimX = control.TopLeftReachablePoint.x, + SimY = gameViewHeight - control.TopLeftReachablePoint.y, + BoundsMinX = bottomLeftBounds.xMin, + BoundsMinY = bottomLeftBounds.yMin, + BoundsMaxX = bottomLeftBounds.xMax, + BoundsMaxY = bottomLeftBounds.yMax, + SortingOrder = control.SortingOrder, + SiblingIndex = control.SiblingIndex + }); } - - return panel.Pick(panelPoint); - } - - private static void Visit(VisualElement element, UIDocument document, VisitContext context) - { - // A child can still resolve to Flex under a display:none parent, so the whole subtree stops here. - if (element.resolvedStyle.display == DisplayStyle.None) - { - return; - } - - string? type = UIToolkitElementClassifier.Classify(element); - if (type != null) - { - TryAdd(element, type, document, context); - - // The parts inside a control (the slider and buttons of a Scroller, for example) are not - // separate controls. - return; - } - - // hierarchy, not Children(): Children() lists only the content container, which hides a - // ScrollView's scrollers. - for (int index = 0; index < element.hierarchy.childCount; index++) - { - Visit(element.hierarchy[index], document, context); - } - } - - private static void TryAdd(VisualElement element, string type, UIDocument document, VisitContext context) - { - if (!element.enabledInHierarchy || element.resolvedStyle.visibility == Visibility.Hidden) - { - return; - } - - Rect worldBound = element.worldBound; - if (!IsFiniteNonEmpty(worldBound)) - { - return; - } - - // Not Screen.height: uloop commands run outside the player loop, where Screen reports the size of the - // last Editor view drawn. ConvertToSimCoordinates flips the result back with the Game View height, so - // the same height is used here. - float gameViewHeight = context.GameViewHeight; - Vector2 topLeftMin = context.Mapping.PanelToScreenTopLeft(new Vector2(worldBound.xMin, worldBound.yMin)); - Vector2 topLeftMax = context.Mapping.PanelToScreenTopLeft(new Vector2(worldBound.xMax, worldBound.yMax)); - Rect bottomLeftBounds = Rect.MinMaxRect( - topLeftMin.x, - gameViewHeight - topLeftMax.y, - topLeftMax.x, - gameViewHeight - topLeftMin.y); - - Vector2? reachable = FindReachablePoint(element, bottomLeftBounds, gameViewHeight, context); - if (reachable == null) - { - return; - } - - context.Elements.Add(CreateElementInfo( - element, type, document, context.Entry, bottomLeftBounds, new Vector2(reachable.Value.x, gameViewHeight - reachable.Value.y))); } // Probes the same points as the uGUI annotator, converted to the top-left origin panels take input in. - private static Vector2? FindReachablePoint( - VisualElement element, Rect bottomLeftBounds, float gameViewHeight, VisitContext context) + private static IReadOnlyList GenerateTopLeftProbePoints(Rect topLeftBounds, float gameViewHeight) { + Rect bottomLeftBounds = ToBottomLeft(topLeftBounds, gameViewHeight); Vector2[] bottomLeftProbes = UIElementAnnotator.GenerateReachabilityProbePoints( bottomLeftBounds.xMin, bottomLeftBounds.yMin, bottomLeftBounds.xMax, bottomLeftBounds.yMax); - Vector2[] topLeftProbes = bottomLeftProbes + return bottomLeftProbes .Select(probe => new Vector2(probe.x, gameViewHeight - probe.y)) .ToArray(); - return UIToolkitReachabilityProbe.FindReachablePoint( - topLeftProbes, element, context.FrontPickers, context.OwnPicker); } - private static UIElementInfo CreateElementInfo( - VisualElement element, - string type, - UIDocument document, - PanelEntry entry, - Rect bottomLeftBounds, - Vector2 bottomLeftPoint) + private static Rect ToBottomLeft(Rect topLeftBounds, float gameViewHeight) { - VisualElement? parent = element.hierarchy.parent; - return new UIElementInfo - { - Name = GetDisplayName(element), - Path = GameObjectPathUtility.GetFullPath(document.gameObject) + PATH_SEPARATOR - + BuildElementChain(document.rootVisualElement, element), - Type = type, - Interaction = UIElementAnnotationStyling.GetInteractionForType(type), - UiSystem = AnnotatedUiSystem.UIToolkit, - SimX = bottomLeftPoint.x, - SimY = bottomLeftPoint.y, - BoundsMinX = bottomLeftBounds.xMin, - BoundsMinY = bottomLeftBounds.yMin, - BoundsMaxX = bottomLeftBounds.xMax, - BoundsMaxY = bottomLeftBounds.yMax, - SortingOrder = Mathf.FloorToInt(entry.SortingOrder), - SiblingIndex = parent == null ? 0 : parent.hierarchy.IndexOf(element) - }; - } - - // Lists the elements from just below the document root down to the element, so the root itself, which - // has no name of its own, is left out. - private static string BuildElementChain(VisualElement root, VisualElement element) - { - List segments = new List(); - VisualElement? current = element; - while (current != null && current != root) - { - segments.Add(GetDisplayName(current)); - current = current.hierarchy.parent; - } - - segments.Reverse(); - return string.Join(CHAIN_SEPARATOR, segments); - } - - private static string GetDisplayName(VisualElement element) - { - return string.IsNullOrEmpty(element.name) ? element.GetType().Name : element.name; - } - - private static bool IsFiniteNonEmpty(Rect rect) - { - return float.IsFinite(rect.x) - && float.IsFinite(rect.y) - && float.IsFinite(rect.width) - && float.IsFinite(rect.height) - && rect.width > 0f - && rect.height > 0f; - } - - private sealed class PanelEntry - { - internal PanelEntry(IPanel panel, float sortingOrder, UIToolkitScreenMapping? mapping) - { - Panel = panel; - SortingOrder = sortingOrder; - Mapping = mapping; - } - - internal IPanel Panel { get; } - internal float SortingOrder { get; } - - // Null when the panel's controls cannot be placed on the screenshot; the panel still covers others. - internal UIToolkitScreenMapping? Mapping { get; } - internal List Documents { get; } = new List(); - } - - private sealed class VisitContext - { - internal VisitContext( - float gameViewHeight, - PanelEntry entry, - UIToolkitScreenMapping mapping, - List> frontPickers, - Func ownPicker, - List elements) - { - GameViewHeight = gameViewHeight; - Entry = entry; - Mapping = mapping; - FrontPickers = frontPickers; - OwnPicker = ownPicker; - Elements = elements; - } - - internal float GameViewHeight { get; } - internal PanelEntry Entry { get; } - internal UIToolkitScreenMapping Mapping { get; } - internal List> FrontPickers { get; } - internal Func OwnPicker { get; } - internal List Elements { get; } + return Rect.MinMaxRect( + topLeftBounds.xMin, + gameViewHeight - topLeftBounds.yMax, + topLeftBounds.xMax, + gameViewHeight - topLeftBounds.yMin); } } } diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UnityCLILoop.FirstPartyTools.Screenshot.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/Screenshot/UnityCLILoop.FirstPartyTools.Screenshot.Editor.asmdef index aa8752700b..fcf49663a7 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/UnityCLILoop.FirstPartyTools.Screenshot.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/UnityCLILoop.FirstPartyTools.Screenshot.Editor.asmdef @@ -5,6 +5,7 @@ "GUID:214998e563c124e8a88199b2dd1f522d", "GUID:5079a8d3a72924a81aa1cbc25f65ed1b", "GUID:a2d87883023de4cf59b7d1962d5dd8aa", + "GUID:569bba870e5e64d0d94144057b927001", "GUID:fc3fd32eddbee40e39c2d76dc184957b", "GUID:96b8d4624fb74ea2849fed17e7ad69d3", "GUID:77087b1bb30a415e87a80cbf24e7430a",