Skip to content

Commit de5999a

Browse files
RogPodgevrdave-unitylyndon-unityjamesmcgill
authored
NEW: Added Derived Bindings underneath the Binding Path editor to show all controls that matched. (#1676)
* Checking-Pointing progress for specifying input usages in editor, as well as listing out the specific device paths that match to a binding * Style update, optimizing performance a bit * added unit test * Control usages now create the right path when combined with device usages, now shows a message when no matching registered paths exist * updated to show relevant information when the path doesn't match any additional contexts * removing whitespace, retriggering formatting CI * fixing remaining formatting complaints by running format.ps1 * tweaked UI to PR suggestions * Update Packages/com.unity.inputsystem/InputSystem/Controls/InputControlPath.cs Co-authored-by: Dave Ruddell <[email protected]> * Adjusted instances of ref to in, fixed instances where break should be used instead of continue * Added a lookup dictionary in the EditorInputControlLayoutCache for derived layout types, updated dictionary to use a hashset for performance * Updated matching controls layout information to an expandable foldout * fixed issue where invalid controls could be selected in the dropdown UI, updated UI with PR feedback * fixed wording, fixed case where the binding path could break * codestyle formatting pass * fixed issue where device layouts were queried twice due to being child layouts of the 'root' base layouts * applying PR feedback * Updated No Matching Control paths message with PR suggestions * Matching controls display now shows control path's whose alias matches the user specified path * fixed bug where controls wouldn't match if only usages were checked * added unit tests for the new control path layout capabilities * fixing formatting issues * Removed reduntant control path listings when the control path specifically doesn't contain usages * formatting fix * relabed the 'matched controls' field to be 'Derived controls' * Fixed to 'Derived Bindings' * fixed case where the wildcard device moniker wasn't detected properly * fixed errors that would occur when malformed control paths are provided * style change to due to code analyzer results * added missing in/ref changes * fixing formatter nits * add changelog entry --------- Co-authored-by: Dave Ruddell <[email protected]> Co-authored-by: Lyndon Homewood <[email protected]> Co-authored-by: James McGill <[email protected]>
1 parent 2bd9307 commit de5999a

File tree

7 files changed

+374
-19
lines changed

7 files changed

+374
-19
lines changed

Assets/Tests/InputSystem/CoreTests_Layouts.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2546,6 +2546,50 @@ public void Layouts_CanForceMixedVariantsThroughLayout()
25462546
Assert.That(device.allControls, Has.Exactly(1).With.Property("name").EqualTo("ButtonC"));
25472547
}
25482548

2549+
[Test]
2550+
[Category("Layouts")]
2551+
public void Layouts_CanMatchControlPath()
2552+
{
2553+
const string jsonBase = @"
2554+
{
2555+
""name"" : ""BaseLayout"",
2556+
""extend"" : ""DeviceWithLayoutVariantA"",
2557+
""controls"" : [
2558+
{ ""name"" : ""ControlFromBase"", ""layout"" : ""Button"" },
2559+
{ ""name"" : ""OtherControlFromBase"", ""layout"" : ""Axis"" },
2560+
{ ""name"" : ""ControlWithExplicitDefaultVariant"", ""layout"" : ""Axis"", ""variants"" : ""default"" },
2561+
{ ""name"" : ""StickControl"", ""layout"" : ""Stick"" },
2562+
{ ""name"" : ""StickControl/x"", ""offset"" : 14, ""variants"" : ""A"" }
2563+
]
2564+
}
2565+
";
2566+
const string jsonDerived = @"
2567+
{
2568+
""name"" : ""DerivedLayout"",
2569+
""extend"" : ""BaseLayout"",
2570+
""controls"" : [
2571+
{ ""name"" : ""ControlFromBase"", ""variants"" : ""A"", ""offset"" : 20, ""usages"" : [""Submit""], ""aliases"" : [""A""] }
2572+
]
2573+
}
2574+
";
2575+
2576+
InputSystem.RegisterLayout<DeviceWithLayoutVariantA>();
2577+
InputSystem.RegisterLayout(jsonBase);
2578+
InputSystem.RegisterLayout(jsonDerived);
2579+
2580+
var layout = InputSystem.LoadLayout("DerivedLayout");
2581+
var parsedPath = InputControlPath.Parse("<BaseLayout>/ControlWithExplicitDefaultVariant").ToArray()[1];
2582+
Assert.That(layout.m_Controls.Any(x => InputControlPath.MatchControlComponent(ref parsedPath, ref x)), Is.True);
2583+
2584+
// Verify that we can match alias's when provided
2585+
var parsedAliasPath = InputControlPath.Parse("<BaseLayout>/A").ToArray()[1];
2586+
Assert.That(layout.m_Controls.Any(x => InputControlPath.MatchControlComponent(ref parsedAliasPath, ref x, true)), Is.True);
2587+
2588+
// Verify that we match usages when it is the only control path component provided
2589+
var parsedUsagesPath = InputControlPath.Parse("<BaseLayout>/{Submit}").ToArray()[1];
2590+
Assert.That(layout.m_Controls.Any(x => InputControlPath.MatchControlComponent(ref parsedUsagesPath, ref x)), Is.True);
2591+
}
2592+
25492593
[Test]
25502594
[Category("Layouts")]
25512595
[Ignore("TODO")]

Packages/com.unity.inputsystem/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ however, it has to be formatted properly to pass verification tests.
1212

1313
### Added
1414
- Preliminary support for visionOS.
15+
- Show a list of `Derived Bindings` underneath the Binding Path editor to show all controls that matched.
1516

1617
### Changed
1718
- 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.

Packages/com.unity.inputsystem/InputSystem/Controls/InputControlPath.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,54 @@ public static bool Matches(string expected, InputControl control)
721721
return MatchesRecursive(ref parser, control);
722722
}
723723

724+
internal static bool MatchControlComponent(ref ParsedPathComponent expectedControlComponent, ref InputControlLayout.ControlItem controlItem, bool matchAlias = false)
725+
{
726+
bool controlItemNameMatched = false;
727+
var anyUsageMatches = false;
728+
729+
// Check to see that there is a match with the name or alias if specified
730+
// Exit early if we can't create a match.
731+
if (!expectedControlComponent.m_Name.isEmpty)
732+
{
733+
if (StringMatches(expectedControlComponent.m_Name, controlItem.name))
734+
controlItemNameMatched = true;
735+
else if (matchAlias)
736+
{
737+
var aliases = controlItem.aliases;
738+
for (var i = 0; i < aliases.Count; i++)
739+
{
740+
if (StringMatches(expectedControlComponent.m_Name, aliases[i]))
741+
{
742+
controlItemNameMatched = true;
743+
break;
744+
}
745+
}
746+
}
747+
else
748+
return false;
749+
}
750+
751+
// All of usages should match to the one of usage in the control
752+
foreach (var usage in expectedControlComponent.m_Usages)
753+
{
754+
if (!usage.isEmpty)
755+
{
756+
var usageCount = controlItem.usages.Count;
757+
for (var i = 0; i < usageCount; ++i)
758+
{
759+
if (StringMatches(usage, controlItem.usages[i]))
760+
{
761+
anyUsageMatches = true;
762+
break;
763+
}
764+
}
765+
}
766+
}
767+
768+
// Return whether or not we were able to match an alias or a usage
769+
return controlItemNameMatched || anyUsageMatches;
770+
}
771+
724772
/// <summary>
725773
/// Check whether the given path matches <paramref name="control"/> or any of its parents.
726774
/// </summary>

Packages/com.unity.inputsystem/InputSystem/Editor/AssetEditor/InputBindingPropertiesView.cs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System;
33
using System.Collections.Generic;
44
using System.Linq;
5+
using System.Reflection;
56
using UnityEditor;
67
using UnityEngine.InputSystem.Editor.Lists;
78
using UnityEngine.InputSystem.Layouts;
@@ -102,11 +103,187 @@ protected override void DrawGeneralProperties()
102103
}
103104
}
104105

106+
// Show the specific controls which match the current path
107+
DrawMatchingControlPaths();
108+
105109
// Control scheme matrix.
106110
DrawUseInControlSchemes();
107111
}
108112
}
109113

114+
/// <summary>
115+
/// Used to keep track of which foldouts are expanded.
116+
/// </summary>
117+
private static bool showMatchingLayouts = false;
118+
private static Dictionary<string, bool> showMatchingChildLayouts = new Dictionary<string, bool>();
119+
120+
/// <summary>
121+
/// Finds all registered control paths implemented by concrete classes which match the current binding path and renders it.
122+
/// </summary>
123+
private void DrawMatchingControlPaths()
124+
{
125+
var path = m_ControlPathEditor.pathProperty.stringValue;
126+
if (path == string.Empty)
127+
return;
128+
129+
var deviceLayoutPath = InputControlPath.TryGetDeviceLayout(path);
130+
var parsedPath = InputControlPath.Parse(path).ToArray();
131+
132+
// If the provided path is parseable into device and control components, draw UI which shows control layouts that match the path.
133+
if (parsedPath.Length >= 2 && !string.IsNullOrEmpty(deviceLayoutPath))
134+
{
135+
bool matchExists = false;
136+
137+
var rootDeviceLayout = EditorInputControlLayoutCache.TryGetLayout(deviceLayoutPath);
138+
bool isValidDeviceLayout = deviceLayoutPath == InputControlPath.Wildcard || (rootDeviceLayout != null && !rootDeviceLayout.isOverride && !rootDeviceLayout.hideInUI);
139+
// Exit early if a malformed device layout was provided,
140+
if (!isValidDeviceLayout)
141+
return;
142+
143+
bool controlPathUsagePresent = parsedPath[1].usages.Count() > 0;
144+
bool hasChildDeviceLayouts = deviceLayoutPath == InputControlPath.Wildcard || EditorInputControlLayoutCache.HasChildLayouts(rootDeviceLayout.name);
145+
146+
// 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
147+
if (!controlPathUsagePresent && !hasChildDeviceLayouts)
148+
return;
149+
150+
// Otherwise, we will show either all controls that match the current binding (if control usages are used)
151+
// or all controls in derived device layouts (if a no control usages are used).
152+
EditorGUILayout.BeginVertical();
153+
showMatchingLayouts = EditorGUILayout.Foldout(showMatchingLayouts, "Derived Bindings");
154+
155+
if (showMatchingLayouts)
156+
{
157+
// If our control path contains a usage, make sure we render the binding that belongs to the root device layout first
158+
if (deviceLayoutPath != InputControlPath.Wildcard && controlPathUsagePresent)
159+
{
160+
matchExists |= DrawMatchingControlPathsForLayout(rootDeviceLayout, in parsedPath, true);
161+
}
162+
// Otherwise, just render the bindings that belong to child device layouts. The binding that matches the root layout is
163+
// already represented by the user generated control path itself.
164+
else
165+
{
166+
IEnumerable<InputControlLayout> matchedChildLayouts = Enumerable.Empty<InputControlLayout>();
167+
if (deviceLayoutPath == InputControlPath.Wildcard)
168+
{
169+
matchedChildLayouts = EditorInputControlLayoutCache.allLayouts
170+
.Where(x => x.isDeviceLayout && !x.hideInUI && !x.isOverride && x.isGenericTypeOfDevice && x.baseLayouts.Count() == 0).OrderBy(x => x.displayName);
171+
}
172+
else
173+
{
174+
matchedChildLayouts = EditorInputControlLayoutCache.TryGetChildLayouts(rootDeviceLayout.name);
175+
}
176+
177+
foreach (var childLayout in matchedChildLayouts)
178+
{
179+
matchExists |= DrawMatchingControlPathsForLayout(childLayout, in parsedPath);
180+
}
181+
}
182+
183+
// Otherwise, indicate that no layouts match the current path.
184+
if (!matchExists)
185+
{
186+
if (controlPathUsagePresent)
187+
EditorGUILayout.HelpBox("No registered controls match this current binding. Some controls are only registered at runtime.", MessageType.Warning);
188+
else
189+
EditorGUILayout.HelpBox("No other registered controls match this current binding. Some controls are only registered at runtime.", MessageType.Warning);
190+
}
191+
}
192+
193+
EditorGUILayout.EndVertical();
194+
}
195+
}
196+
197+
/// <summary>
198+
/// Returns true if the deviceLayout or any of its children has controls which match the provided parsed path. exist matching registered control paths.
199+
/// </summary>
200+
/// <param name="deviceLayout">The device layout to draw control paths for</param>
201+
/// <param name="parsedPath">The parsed path containing details of the Input Controls that can be matched</param>
202+
private bool DrawMatchingControlPathsForLayout(InputControlLayout deviceLayout, in InputControlPath.ParsedPathComponent[] parsedPath, bool isRoot = false)
203+
{
204+
string deviceName = deviceLayout.displayName;
205+
string controlName = string.Empty;
206+
bool matchExists = false;
207+
208+
for (int i = 0; i < deviceLayout.m_Controls.Length; i++)
209+
{
210+
ref InputControlLayout.ControlItem controlItem = ref deviceLayout.m_Controls[i];
211+
if (InputControlPath.MatchControlComponent(ref parsedPath[1], ref controlItem, true))
212+
{
213+
// If we've already located a match, append a ", " to the control name
214+
// This is to accomodate cases where multiple control items match the same path within a single device layout
215+
// Note, some controlItems have names but invalid displayNames (i.e. the Dualsense HID > leftTriggerButton)
216+
// There are instance where there are 2 control items with the same name inside a layout definition, however they are not
217+
// labeled significantly differently.
218+
// The notable example is that the Android Xbox and Android Dualshock layouts have 2 d-pad definitions, one is a "button"
219+
// while the other is an axis.
220+
controlName += matchExists ? $", {controlItem.name}" : controlItem.name;
221+
222+
// if the parsePath has a 3rd component, try to match it with items in the controlItem's layout definition.
223+
if (parsedPath.Length == 3)
224+
{
225+
var controlLayout = EditorInputControlLayoutCache.TryGetLayout(controlItem.layout);
226+
if (controlLayout.isControlLayout && !controlLayout.hideInUI)
227+
{
228+
for (int j = 0; j < controlLayout.m_Controls.Count(); j++)
229+
{
230+
ref InputControlLayout.ControlItem controlLayoutItem = ref controlLayout.m_Controls[j];
231+
if (InputControlPath.MatchControlComponent(ref parsedPath[2], ref controlLayoutItem))
232+
{
233+
controlName += $"/{controlLayoutItem.name}";
234+
matchExists = true;
235+
}
236+
}
237+
}
238+
}
239+
else
240+
{
241+
matchExists = true;
242+
}
243+
}
244+
}
245+
246+
IEnumerable<InputControlLayout> matchedChildLayouts = EditorInputControlLayoutCache.TryGetChildLayouts(deviceLayout.name);
247+
248+
// If this layout does not have a match, or is the top level root layout,
249+
// skip over trying to draw any items for it, and immediately try processing the child layouts
250+
if (!matchExists)
251+
{
252+
foreach (var childLayout in matchedChildLayouts)
253+
{
254+
matchExists |= DrawMatchingControlPathsForLayout(childLayout, in parsedPath);
255+
}
256+
}
257+
// Otherwise, draw the items for it, and then only process the child layouts if the foldout is expanded.
258+
else
259+
{
260+
bool showLayout = false;
261+
EditorGUI.indentLevel++;
262+
if (matchedChildLayouts.Count() > 0 && !isRoot)
263+
{
264+
showMatchingChildLayouts.TryGetValue(deviceName, out showLayout);
265+
showMatchingChildLayouts[deviceName] = EditorGUILayout.Foldout(showLayout, $"{deviceName} > {controlName}");
266+
}
267+
else
268+
{
269+
EditorGUILayout.LabelField($"{deviceName} > {controlName}");
270+
}
271+
272+
showLayout |= isRoot;
273+
274+
if (showLayout)
275+
{
276+
foreach (var childLayout in matchedChildLayouts)
277+
{
278+
DrawMatchingControlPathsForLayout(childLayout, in parsedPath);
279+
}
280+
}
281+
EditorGUI.indentLevel--;
282+
}
283+
284+
return matchExists;
285+
}
286+
110287
/// <summary>
111288
/// Draw control scheme matrix that allows selecting which control schemes a particular
112289
/// binding appears in.

Packages/com.unity.inputsystem/InputSystem/Editor/ControlPicker/InputControlDropdownItem.cs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,31 @@ public OptionalControlDropdownItem(EditorInputControlLayoutCache.OptionalControl
7070
}
7171
}
7272

73-
internal sealed class UsageDropdownItem : InputControlDropdownItem
73+
internal sealed class ControlUsageDropdownItem : InputControlDropdownItem
7474
{
75-
public override string controlPathWithDevice => $"{m_Device}/{{{m_ControlPath}}}";
75+
public override string controlPathWithDevice => BuildControlPath();
76+
private string BuildControlPath()
77+
{
78+
if (m_Device == "*")
79+
{
80+
var path = new StringBuilder(m_Device);
81+
if (!string.IsNullOrEmpty(m_Usage))
82+
path.Append($"{{{m_Usage}}}");
83+
if (!string.IsNullOrEmpty(m_ControlPath))
84+
path.Append($"/{m_ControlPath}");
85+
return path.ToString();
86+
}
87+
else
88+
return base.controlPathWithDevice;
89+
}
7690

77-
public UsageDropdownItem(string usage)
91+
public ControlUsageDropdownItem(string device, string usage, string controlUsage)
7892
: base(usage)
7993
{
80-
m_Device = "*";
81-
m_ControlPath = usage;
94+
m_Device = string.IsNullOrEmpty(device) ? "*" : device;
95+
m_Usage = usage;
96+
m_ControlPath = $"{{{ controlUsage }}}";
97+
name = controlUsage;
8298
id = controlPathWithDevice.GetHashCode();
8399
m_Searchable = true;
84100
}

0 commit comments

Comments
 (0)