diff --git a/Assets/Tests/InputSystem/CoreTests_Layouts.cs b/Assets/Tests/InputSystem/CoreTests_Layouts.cs index a3b76d5f88..38af2abaaa 100644 --- a/Assets/Tests/InputSystem/CoreTests_Layouts.cs +++ b/Assets/Tests/InputSystem/CoreTests_Layouts.cs @@ -2546,6 +2546,50 @@ public void Layouts_CanForceMixedVariantsThroughLayout() Assert.That(device.allControls, Has.Exactly(1).With.Property("name").EqualTo("ButtonC")); } + [Test] + [Category("Layouts")] + public void Layouts_CanMatchControlPath() + { + const string jsonBase = @" + { + ""name"" : ""BaseLayout"", + ""extend"" : ""DeviceWithLayoutVariantA"", + ""controls"" : [ + { ""name"" : ""ControlFromBase"", ""layout"" : ""Button"" }, + { ""name"" : ""OtherControlFromBase"", ""layout"" : ""Axis"" }, + { ""name"" : ""ControlWithExplicitDefaultVariant"", ""layout"" : ""Axis"", ""variants"" : ""default"" }, + { ""name"" : ""StickControl"", ""layout"" : ""Stick"" }, + { ""name"" : ""StickControl/x"", ""offset"" : 14, ""variants"" : ""A"" } + ] + } + "; + const string jsonDerived = @" + { + ""name"" : ""DerivedLayout"", + ""extend"" : ""BaseLayout"", + ""controls"" : [ + { ""name"" : ""ControlFromBase"", ""variants"" : ""A"", ""offset"" : 20, ""usages"" : [""Submit""], ""aliases"" : [""A""] } + ] + } + "; + + InputSystem.RegisterLayout(); + InputSystem.RegisterLayout(jsonBase); + InputSystem.RegisterLayout(jsonDerived); + + var layout = InputSystem.LoadLayout("DerivedLayout"); + var parsedPath = InputControlPath.Parse("/ControlWithExplicitDefaultVariant").ToArray()[1]; + Assert.That(layout.m_Controls.Any(x => InputControlPath.MatchControlComponent(ref parsedPath, ref x)), Is.True); + + // Verify that we can match alias's when provided + var parsedAliasPath = InputControlPath.Parse("/A").ToArray()[1]; + Assert.That(layout.m_Controls.Any(x => InputControlPath.MatchControlComponent(ref parsedAliasPath, ref x, true)), Is.True); + + // Verify that we match usages when it is the only control path component provided + var parsedUsagesPath = InputControlPath.Parse("/{Submit}").ToArray()[1]; + Assert.That(layout.m_Controls.Any(x => InputControlPath.MatchControlComponent(ref parsedUsagesPath, ref x)), Is.True); + } + [Test] [Category("Layouts")] [Ignore("TODO")] diff --git a/Packages/com.unity.inputsystem/CHANGELOG.md b/Packages/com.unity.inputsystem/CHANGELOG.md index 5d42de8189..168f60c8ce 100644 --- a/Packages/com.unity.inputsystem/CHANGELOG.md +++ b/Packages/com.unity.inputsystem/CHANGELOG.md @@ -12,6 +12,7 @@ however, it has to be formatted properly to pass verification tests. ### Added - Preliminary support for visionOS. +- Show a list of `Derived Bindings` underneath the Binding Path editor to show all controls that matched. ### Changed - Changed the `InputAction` constructors so it generates an ID for the action and the optional binding parameter. This is intended to improve the serialization of input actions on behaviors when created through API when the property drawer in the Inspector window does not have a chance to generate an ID. diff --git a/Packages/com.unity.inputsystem/InputSystem/Controls/InputControlPath.cs b/Packages/com.unity.inputsystem/InputSystem/Controls/InputControlPath.cs index c4babf93a6..d2b42d39b3 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Controls/InputControlPath.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Controls/InputControlPath.cs @@ -721,6 +721,54 @@ public static bool Matches(string expected, InputControl control) return MatchesRecursive(ref parser, control); } + internal static bool MatchControlComponent(ref ParsedPathComponent expectedControlComponent, ref InputControlLayout.ControlItem controlItem, bool matchAlias = false) + { + bool controlItemNameMatched = false; + var anyUsageMatches = false; + + // Check to see that there is a match with the name or alias if specified + // Exit early if we can't create a match. + if (!expectedControlComponent.m_Name.isEmpty) + { + if (StringMatches(expectedControlComponent.m_Name, controlItem.name)) + controlItemNameMatched = true; + else if (matchAlias) + { + var aliases = controlItem.aliases; + for (var i = 0; i < aliases.Count; i++) + { + if (StringMatches(expectedControlComponent.m_Name, aliases[i])) + { + controlItemNameMatched = true; + break; + } + } + } + else + return false; + } + + // All of usages should match to the one of usage in the control + foreach (var usage in expectedControlComponent.m_Usages) + { + if (!usage.isEmpty) + { + var usageCount = controlItem.usages.Count; + for (var i = 0; i < usageCount; ++i) + { + if (StringMatches(usage, controlItem.usages[i])) + { + anyUsageMatches = true; + break; + } + } + } + } + + // Return whether or not we were able to match an alias or a usage + return controlItemNameMatched || anyUsageMatches; + } + /// /// Check whether the given path matches or any of its parents. /// diff --git a/Packages/com.unity.inputsystem/InputSystem/Editor/AssetEditor/InputBindingPropertiesView.cs b/Packages/com.unity.inputsystem/InputSystem/Editor/AssetEditor/InputBindingPropertiesView.cs index d8de344c92..8050d99a7c 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Editor/AssetEditor/InputBindingPropertiesView.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Editor/AssetEditor/InputBindingPropertiesView.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using UnityEditor; using UnityEngine.InputSystem.Editor.Lists; using UnityEngine.InputSystem.Layouts; @@ -102,11 +103,187 @@ protected override void DrawGeneralProperties() } } + // Show the specific controls which match the current path + DrawMatchingControlPaths(); + // Control scheme matrix. DrawUseInControlSchemes(); } } + /// + /// Used to keep track of which foldouts are expanded. + /// + private static bool showMatchingLayouts = false; + private static Dictionary showMatchingChildLayouts = new Dictionary(); + + /// + /// Finds all registered control paths implemented by concrete classes which match the current binding path and renders it. + /// + private void DrawMatchingControlPaths() + { + var path = m_ControlPathEditor.pathProperty.stringValue; + if (path == string.Empty) + return; + + var deviceLayoutPath = InputControlPath.TryGetDeviceLayout(path); + var parsedPath = InputControlPath.Parse(path).ToArray(); + + // If the provided path is parseable into device and control components, draw UI which shows control layouts that match the path. + if (parsedPath.Length >= 2 && !string.IsNullOrEmpty(deviceLayoutPath)) + { + bool matchExists = false; + + var rootDeviceLayout = EditorInputControlLayoutCache.TryGetLayout(deviceLayoutPath); + bool isValidDeviceLayout = deviceLayoutPath == InputControlPath.Wildcard || (rootDeviceLayout != null && !rootDeviceLayout.isOverride && !rootDeviceLayout.hideInUI); + // Exit early if a malformed device layout was provided, + if (!isValidDeviceLayout) + return; + + bool controlPathUsagePresent = parsedPath[1].usages.Count() > 0; + bool hasChildDeviceLayouts = deviceLayoutPath == InputControlPath.Wildcard || EditorInputControlLayoutCache.HasChildLayouts(rootDeviceLayout.name); + + // If the path provided matches exactly one control path (i.e. has no ui-facing child device layouts or uses control usages), then exit early + if (!controlPathUsagePresent && !hasChildDeviceLayouts) + return; + + // Otherwise, we will show either all controls that match the current binding (if control usages are used) + // or all controls in derived device layouts (if a no control usages are used). + EditorGUILayout.BeginVertical(); + showMatchingLayouts = EditorGUILayout.Foldout(showMatchingLayouts, "Derived Bindings"); + + if (showMatchingLayouts) + { + // If our control path contains a usage, make sure we render the binding that belongs to the root device layout first + if (deviceLayoutPath != InputControlPath.Wildcard && controlPathUsagePresent) + { + matchExists |= DrawMatchingControlPathsForLayout(rootDeviceLayout, in parsedPath, true); + } + // Otherwise, just render the bindings that belong to child device layouts. The binding that matches the root layout is + // already represented by the user generated control path itself. + else + { + IEnumerable matchedChildLayouts = Enumerable.Empty(); + if (deviceLayoutPath == InputControlPath.Wildcard) + { + matchedChildLayouts = EditorInputControlLayoutCache.allLayouts + .Where(x => x.isDeviceLayout && !x.hideInUI && !x.isOverride && x.isGenericTypeOfDevice && x.baseLayouts.Count() == 0).OrderBy(x => x.displayName); + } + else + { + matchedChildLayouts = EditorInputControlLayoutCache.TryGetChildLayouts(rootDeviceLayout.name); + } + + foreach (var childLayout in matchedChildLayouts) + { + matchExists |= DrawMatchingControlPathsForLayout(childLayout, in parsedPath); + } + } + + // Otherwise, indicate that no layouts match the current path. + if (!matchExists) + { + if (controlPathUsagePresent) + EditorGUILayout.HelpBox("No registered controls match this current binding. Some controls are only registered at runtime.", MessageType.Warning); + else + EditorGUILayout.HelpBox("No other registered controls match this current binding. Some controls are only registered at runtime.", MessageType.Warning); + } + } + + EditorGUILayout.EndVertical(); + } + } + + /// + /// Returns true if the deviceLayout or any of its children has controls which match the provided parsed path. exist matching registered control paths. + /// + /// The device layout to draw control paths for + /// The parsed path containing details of the Input Controls that can be matched + private bool DrawMatchingControlPathsForLayout(InputControlLayout deviceLayout, in InputControlPath.ParsedPathComponent[] parsedPath, bool isRoot = false) + { + string deviceName = deviceLayout.displayName; + string controlName = string.Empty; + bool matchExists = false; + + for (int i = 0; i < deviceLayout.m_Controls.Length; i++) + { + ref InputControlLayout.ControlItem controlItem = ref deviceLayout.m_Controls[i]; + if (InputControlPath.MatchControlComponent(ref parsedPath[1], ref controlItem, true)) + { + // If we've already located a match, append a ", " to the control name + // This is to accomodate cases where multiple control items match the same path within a single device layout + // Note, some controlItems have names but invalid displayNames (i.e. the Dualsense HID > leftTriggerButton) + // There are instance where there are 2 control items with the same name inside a layout definition, however they are not + // labeled significantly differently. + // The notable example is that the Android Xbox and Android Dualshock layouts have 2 d-pad definitions, one is a "button" + // while the other is an axis. + controlName += matchExists ? $", {controlItem.name}" : controlItem.name; + + // if the parsePath has a 3rd component, try to match it with items in the controlItem's layout definition. + if (parsedPath.Length == 3) + { + var controlLayout = EditorInputControlLayoutCache.TryGetLayout(controlItem.layout); + if (controlLayout.isControlLayout && !controlLayout.hideInUI) + { + for (int j = 0; j < controlLayout.m_Controls.Count(); j++) + { + ref InputControlLayout.ControlItem controlLayoutItem = ref controlLayout.m_Controls[j]; + if (InputControlPath.MatchControlComponent(ref parsedPath[2], ref controlLayoutItem)) + { + controlName += $"/{controlLayoutItem.name}"; + matchExists = true; + } + } + } + } + else + { + matchExists = true; + } + } + } + + IEnumerable matchedChildLayouts = EditorInputControlLayoutCache.TryGetChildLayouts(deviceLayout.name); + + // If this layout does not have a match, or is the top level root layout, + // skip over trying to draw any items for it, and immediately try processing the child layouts + if (!matchExists) + { + foreach (var childLayout in matchedChildLayouts) + { + matchExists |= DrawMatchingControlPathsForLayout(childLayout, in parsedPath); + } + } + // Otherwise, draw the items for it, and then only process the child layouts if the foldout is expanded. + else + { + bool showLayout = false; + EditorGUI.indentLevel++; + if (matchedChildLayouts.Count() > 0 && !isRoot) + { + showMatchingChildLayouts.TryGetValue(deviceName, out showLayout); + showMatchingChildLayouts[deviceName] = EditorGUILayout.Foldout(showLayout, $"{deviceName} > {controlName}"); + } + else + { + EditorGUILayout.LabelField($"{deviceName} > {controlName}"); + } + + showLayout |= isRoot; + + if (showLayout) + { + foreach (var childLayout in matchedChildLayouts) + { + DrawMatchingControlPathsForLayout(childLayout, in parsedPath); + } + } + EditorGUI.indentLevel--; + } + + return matchExists; + } + /// /// Draw control scheme matrix that allows selecting which control schemes a particular /// binding appears in. diff --git a/Packages/com.unity.inputsystem/InputSystem/Editor/ControlPicker/InputControlDropdownItem.cs b/Packages/com.unity.inputsystem/InputSystem/Editor/ControlPicker/InputControlDropdownItem.cs index f473cdb830..a1bc18709d 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Editor/ControlPicker/InputControlDropdownItem.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Editor/ControlPicker/InputControlDropdownItem.cs @@ -70,15 +70,31 @@ public OptionalControlDropdownItem(EditorInputControlLayoutCache.OptionalControl } } - internal sealed class UsageDropdownItem : InputControlDropdownItem + internal sealed class ControlUsageDropdownItem : InputControlDropdownItem { - public override string controlPathWithDevice => $"{m_Device}/{{{m_ControlPath}}}"; + public override string controlPathWithDevice => BuildControlPath(); + private string BuildControlPath() + { + if (m_Device == "*") + { + var path = new StringBuilder(m_Device); + if (!string.IsNullOrEmpty(m_Usage)) + path.Append($"{{{m_Usage}}}"); + if (!string.IsNullOrEmpty(m_ControlPath)) + path.Append($"/{m_ControlPath}"); + return path.ToString(); + } + else + return base.controlPathWithDevice; + } - public UsageDropdownItem(string usage) + public ControlUsageDropdownItem(string device, string usage, string controlUsage) : base(usage) { - m_Device = "*"; - m_ControlPath = usage; + m_Device = string.IsNullOrEmpty(device) ? "*" : device; + m_Usage = usage; + m_ControlPath = $"{{{ controlUsage }}}"; + name = controlUsage; id = controlPathWithDevice.GetHashCode(); m_Searchable = true; } diff --git a/Packages/com.unity.inputsystem/InputSystem/Editor/ControlPicker/InputControlPickerDropdown.cs b/Packages/com.unity.inputsystem/InputSystem/Editor/ControlPicker/InputControlPickerDropdown.cs index 27aeb10d5a..5f595a6a1d 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Editor/ControlPicker/InputControlPickerDropdown.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Editor/ControlPicker/InputControlPickerDropdown.cs @@ -81,7 +81,7 @@ protected override AdvancedDropdownItem BuildRoot() // Usages. if (m_Mode != InputControlPicker.Mode.PickDevice) { - var usages = BuildTreeForUsages(); + var usages = BuildTreeForControlUsages(); if (usages.children.Any()) { root.AddChild(usages); @@ -124,14 +124,14 @@ protected override void ItemSelected(AdvancedDropdownItem item) m_OnPickCallback(path); } - private AdvancedDropdownItem BuildTreeForUsages() + private AdvancedDropdownItem BuildTreeForControlUsages(string device = "", string usage = "") { var usageRoot = new AdvancedDropdownItem("Usages"); foreach (var usageAndLayouts in EditorInputControlLayoutCache.allUsages) { if (usageAndLayouts.Item2.Any(LayoutMatchesExpectedControlLayoutFilter)) { - var child = new UsageDropdownItem(usageAndLayouts.Item1); + var child = new ControlUsageDropdownItem(device, usage, usageAndLayouts.Item1); usageRoot.AddChild(child); } } @@ -183,12 +183,21 @@ private void AddDeviceTreeItemRecursive(InputControlLayout layout, AdvancedDropd var defaultControlPickerLayout = new DefaultInputControlPickerLayout(); - // Add common usage variants. + // Add common usage variants of the device if (layout.commonUsages.Count > 0) { foreach (var usage in layout.commonUsages) { var usageItem = new DeviceDropdownItem(layout, usage); + + // Add control usages to the device variants + var deviceVariantControlUsages = BuildTreeForControlUsages(layout.name, usage); + if (deviceVariantControlUsages.children.Any()) + { + usageItem.AddChild(deviceVariantControlUsages); + usageItem.AddSeparator(); + } + if (m_Mode == InputControlPicker.Mode.PickControl) AddControlTreeItemsRecursive(defaultControlPickerLayout, layout, usageItem, layout.name, usage, searchable); deviceItem.AddChild(usageItem); @@ -196,6 +205,14 @@ private void AddDeviceTreeItemRecursive(InputControlLayout layout, AdvancedDropd deviceItem.AddSeparator(); } + // Add control usages + var deviceControlUsages = BuildTreeForControlUsages(layout.name); + if (deviceControlUsages.children.Any()) + { + deviceItem.AddChild(deviceControlUsages); + deviceItem.AddSeparator(); + } + // Add controls. if (m_Mode != InputControlPicker.Mode.PickDevice) { diff --git a/Packages/com.unity.inputsystem/InputSystem/Editor/EditorInputControlLayoutCache.cs b/Packages/com.unity.inputsystem/InputSystem/Editor/EditorInputControlLayoutCache.cs index a559c95691..81dac4b311 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Editor/EditorInputControlLayoutCache.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Editor/EditorInputControlLayoutCache.cs @@ -74,6 +74,36 @@ public static IEnumerable allProductLayouts } } + public static bool HasChildLayouts(string layoutName) + { + if (string.IsNullOrEmpty(layoutName)) + throw new ArgumentException("Layout name cannot be null or empty", nameof(layoutName)); + + Refresh(); + + var internedLayout = new InternedString(layoutName); + // return nothing is the layout does not have any derivations + return s_DeviceChildLayouts.TryGetValue(internedLayout, out var derivations) && derivations.Count > 0; + } + + public static IEnumerable TryGetChildLayouts(string layoutName) + { + if (string.IsNullOrEmpty(layoutName)) + throw new ArgumentException("Layout name cannot be null or empty", nameof(layoutName)); + + Refresh(); + + var internedLayout = new InternedString(layoutName); + // return nothing is the layout does not have any derivations + if (!s_DeviceChildLayouts.TryGetValue(internedLayout, out var derivations)) + yield break; + else + { + foreach (var name in derivations) + yield return InputControlLayout.cache.FindOrLoadLayout(name.ToString()); + } + } + public static InputControlLayout TryGetLayout(string layoutName) { if (string.IsNullOrEmpty(layoutName)) @@ -310,10 +340,15 @@ private static void Refresh() private static Dictionary s_Icons = new Dictionary(); + // We keep a map of the devices which a derived from a base device. + private static readonly Dictionary> s_DeviceChildLayouts = + new Dictionary>(); + + // We keep a map of all unique usages we find in layouts and also // retain a list of the layouts they are used with. - private static readonly SortedDictionary> s_Usages = - new SortedDictionary>(); + private static readonly SortedDictionary> s_Usages = + new SortedDictionary>(); private static void ScanLayout(InputControlLayout layout) { @@ -327,7 +362,9 @@ private static void ScanLayout(InputControlLayout layout) // // NOTE: We're looking at layouts post-merging here. Means we have already picked up all the // controls present on the base. - if (control.isFirstDefinedInThisLayout && !control.isModifyingExistingControl && !control.layout.IsEmpty()) + // Only controls which belong to UI-facing layouts are included, as optional controls are used solely by + // the InputControlPickerDropdown UI + if (control.isFirstDefinedInThisLayout && !control.isModifyingExistingControl && !control.layout.IsEmpty() && !layout.hideInUI) { foreach (var baseLayout in layout.baseLayouts) AddOptionalControlRecursive(baseLayout, ref control); @@ -343,17 +380,32 @@ private static void ScanLayout(InputControlLayout layout) var internedUsage = new InternedString(usage); var internedLayout = new InternedString(control.layout); - if (!s_Usages.TryGetValue(internedUsage, out var layoutList)) + if (!s_Usages.TryGetValue(internedUsage, out var layoutSet)) { - layoutList = new List {internedLayout}; - s_Usages[internedUsage] = layoutList; + layoutSet = new HashSet { internedLayout }; + s_Usages[internedUsage] = layoutSet; } else { - var layoutAlreadyInList = - layoutList.Any(x => x == internedLayout); - if (!layoutAlreadyInList) - layoutList.Add(internedLayout); + layoutSet.Add(internedLayout); + } + } + + // Create a dependency tree matching each concrete device layout exposed in the UI + // to all of the layouts that are directly derived from it. + if (layout.isDeviceLayout && !layout.hideInUI) + { + foreach (var baseLayoutName in layout.baseLayouts) + { + if (!s_DeviceChildLayouts.TryGetValue(baseLayoutName, out var derivedSet)) + { + derivedSet = new HashSet { layout.name }; + s_DeviceChildLayouts[baseLayoutName] = derivedSet; + } + else + { + derivedSet.Add(layout.name); + } } } }