UI 자동화를 위해 바인딩 기능 구현

- 유니티 에셋 인증 오류로 meta 재생성
This commit is contained in:
2026-01-25 01:31:34 +09:00
parent 2ceb28f55d
commit ce83f21c93
1861 changed files with 377882 additions and 211 deletions

View File

@@ -0,0 +1,13 @@
#if UNITY_EDITOR
using UnityEngine.InputSystem.Layouts;
namespace UnityEngine.InputSystem.Editor
{
internal interface IInputControlPickerLayout
{
void AddControlItem(InputControlPickerDropdown dropdown, DeviceDropdownItem parent,
ControlDropdownItem parentControl,
InputControlLayout.ControlItem control, string device, string usage, bool searchable);
}
}
#endif // UNITY_EDITOR

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4f4390a0cc25d424d9adc1d6d6699a1f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,139 @@
#if UNITY_EDITOR
using System.Text;
using UnityEditor;
using UnityEngine.InputSystem.Layouts;
namespace UnityEngine.InputSystem.Editor
{
internal abstract class InputControlDropdownItem : AdvancedDropdownItem
{
protected string m_ControlPath;
protected string m_Device;
protected string m_Usage;
protected bool m_Searchable;
public string controlPath => m_ControlPath;
public virtual string controlPathWithDevice
{
get
{
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();
}
}
public override string searchableName
{
get
{
// ToHumanReadableString is expensive, especially given that we build the whole tree
// every time the control picker comes up. Build searchable names only on demand
// to save some time.
if (m_SearchableName == null)
{
if (m_Searchable)
m_SearchableName = InputControlPath.ToHumanReadableString(controlPathWithDevice);
else
m_SearchableName = string.Empty;
}
return m_SearchableName;
}
}
protected InputControlDropdownItem(string name)
: base(name) {}
}
// NOTE: Optional control items, unlike normal control items, are displayed with their internal control
// names rather that their display names. The reason is that we're looking at controls that have
// the same internal name in one or more derived layouts but each of those derived layouts may
// give the control a different display name.
//
// Also, if we generate a control path for an optional binding, InputControlPath.ToHumanReadableName()
// not find the referenced control on the referenced device layout and will thus not be able to
// find a display name for it either. So, in the binding UI, these paths will also show with their
// internal control names rather than display names.
internal sealed class OptionalControlDropdownItem : InputControlDropdownItem
{
public OptionalControlDropdownItem(EditorInputControlLayoutCache.OptionalControl optionalControl, string deviceControlId, string commonUsage)
: base(optionalControl.name)
{
m_ControlPath = optionalControl.name;
m_Device = deviceControlId;
m_Usage = commonUsage;
// Not searchable.
}
}
internal sealed class ControlUsageDropdownItem : InputControlDropdownItem
{
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 ControlUsageDropdownItem(string device, string usage, string controlUsage)
: base(usage)
{
m_Device = string.IsNullOrEmpty(device) ? "*" : device;
m_Usage = usage;
m_ControlPath = $"{{{ controlUsage }}}";
name = controlUsage;
id = controlPathWithDevice.GetHashCode();
m_Searchable = true;
}
}
internal sealed class DeviceDropdownItem : InputControlDropdownItem
{
public DeviceDropdownItem(InputControlLayout layout, string usage = null, bool searchable = true)
: base(layout.m_DisplayName ?? ObjectNames.NicifyVariableName(layout.name))
{
m_Device = layout.name;
m_Usage = usage;
if (usage != null)
name += " (" + usage + ")";
id = name.GetHashCode();
m_Searchable = searchable;
}
}
internal sealed class ControlDropdownItem : InputControlDropdownItem
{
public ControlDropdownItem(ControlDropdownItem parent, string controlName, string displayName, string device, string usage, bool searchable)
: base("")
{
m_Device = device;
m_Usage = usage;
m_Searchable = searchable;
if (parent != null)
m_ControlPath = $"{parent.controlPath}/{controlName}";
else
m_ControlPath = controlName;
name = !string.IsNullOrEmpty(displayName) ? displayName : ObjectNames.NicifyVariableName(controlName);
id = controlPathWithDevice.GetHashCode();
indent = parent?.indent + 1 ?? 0;
}
}
}
#endif // UNITY_EDITOR

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 83070f7befc0b4deeb2cfde76b5a9002
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,223 @@
#if UNITY_EDITOR || PACKAGE_DOCS_GENERATION
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityEditor;
using UnityEngine.InputSystem.Layouts;
namespace UnityEngine.InputSystem.Editor
{
/// <summary>
/// Custom editor UI for editing control paths.
/// </summary>
/// <remarks>
/// This is the implementation underlying <see cref="InputControlPathDrawer"/>. It is useful primarily when
/// greater control is required than is offered by the <see cref="PropertyDrawer"/> mechanism. In particular,
/// it allows applying additional constraints such as requiring control paths to match ...
/// </remarks>
public sealed class InputControlPathEditor : IDisposable
{
/// <summary>
/// Initialize the control path editor.
/// </summary>
/// <param name="pathProperty"><see cref="string"/> type property that will receive the picked input control path.</param>
/// <param name="pickerState">Persistent editing state of the path editor. Used to retain state across domain reloads.</param>
/// <param name="onModified">Delegate that is called when the path has been modified.</param>
/// <param name="label">Optional label to display instead of display name of <paramref name="pathProperty"/>.</param>
/// <exception cref="ArgumentNullException"><paramref name="pathProperty"/> is <c>null</c>.</exception>
public InputControlPathEditor(SerializedProperty pathProperty, InputControlPickerState pickerState, Action onModified, GUIContent label = null)
{
if (pathProperty == null)
throw new ArgumentNullException(nameof(pathProperty));
this.pathProperty = pathProperty;
this.onModified = onModified;
m_PickerState = pickerState ?? new InputControlPickerState();
m_PathLabel = label ?? new GUIContent(pathProperty.displayName, pathProperty.GetTooltip());
}
public void Dispose()
{
m_PickerDropdown?.Dispose();
}
public void SetControlPathsToMatch(IEnumerable<string> controlPaths)
{
m_ControlPathsToMatch = controlPaths.ToArray();
m_PickerDropdown?.SetControlPathsToMatch(m_ControlPathsToMatch);
}
/// <summary>
/// Constrain the type of control layout that can be picked.
/// </summary>
/// <param name="expectedControlLayout">Name of the layout. This it the name as registered with
/// <see cref="InputSystem.RegisterLayout"/>.</param>.
/// <remarks>
/// <example>
/// <code>
/// // Pick only button controls.
/// editor.SetExpectedControlLayout("Button");
/// </code>
/// </example>
/// </remarks>
public void SetExpectedControlLayout(string expectedControlLayout)
{
m_ExpectedControlLayout = expectedControlLayout;
m_PickerDropdown?.SetExpectedControlLayout(m_ExpectedControlLayout);
}
public void SetExpectedControlLayoutFromAttribute()
{
var field = pathProperty.GetField();
if (field == null)
return;
var attribute = field.GetCustomAttribute<InputControlAttribute>();
if (attribute != null)
SetExpectedControlLayout(attribute.layout);
}
public void OnGUI()
{
EditorGUILayout.BeginHorizontal();
////FIXME: for some reason, the left edge doesn't align properly in GetRect()'s result; indentation issue?
var rect = GUILayoutUtility.GetRect(0, EditorGUIUtility.singleLineHeight);
rect.x += EditorGUIUtility.standardVerticalSpacing + 2;
rect.width -= EditorGUIUtility.standardVerticalSpacing * 2 + 4;
OnGUI(rect);
EditorGUILayout.EndHorizontal();
}
public void OnGUI(Rect rect, GUIContent label = null, SerializedProperty property = null, Action modifiedCallback = null)
{
var pathLabel = label ?? m_PathLabel;
var serializedProperty = property ?? pathProperty;
var lineRect = rect;
var labelRect = lineRect;
labelRect.width = EditorStyles.label.CalcSize(pathLabel).x + 20; // Fit to label with some padding
EditorGUI.LabelField(labelRect, pathLabel);
lineRect.x += labelRect.width;
lineRect.width -= labelRect.width;
var bindingTextRect = lineRect;
var editButtonRect = lineRect;
bindingTextRect.x = labelRect.x + labelRect.width; // Place directly after labelRect
editButtonRect.x += lineRect.width - 20; // Place at the edge of the window to appear after bindingTextRect
bindingTextRect.width = editButtonRect.x - bindingTextRect.x; // bindingTextRect fills remaining space between label and editButton
editButtonRect.width = 20;
editButtonRect.height = 15;
var path = String.Empty;
try
{
path = serializedProperty.stringValue;
}
catch
{
// This try-catch block is a temporary fix for ISX-1436
// The plan is to convert InputControlPathEditor entirely to UITK and therefore this fix will
// no longer be required.
return;
}
////TODO: this should be cached; generates needless GC churn
var displayName = InputControlPath.ToHumanReadableString(path);
// Either show dropdown control that opens path picker or show path directly as
// text, if manual path editing is toggled on.
if (m_PickerState.manualPathEditMode)
{
////FIXME: for some reason the text field does not fill all the rect but rather adds large padding on the left
bindingTextRect.x -= 15;
bindingTextRect.width += 15;
EditorGUI.BeginChangeCheck();
path = EditorGUI.DelayedTextField(bindingTextRect, path);
if (EditorGUI.EndChangeCheck())
{
serializedProperty.stringValue = path;
serializedProperty.serializedObject.ApplyModifiedProperties();
(modifiedCallback ?? onModified).Invoke();
}
}
else
{
// Dropdown that shows binding text and allows opening control picker.
if (EditorGUI.DropdownButton(bindingTextRect, new GUIContent(displayName), FocusType.Keyboard))
{
SetExpectedControlLayoutFromAttribute(serializedProperty);
////TODO: for bindings that are part of composites, use the layout information from the [InputControl] attribute on the field
ShowDropdown(bindingTextRect, serializedProperty, modifiedCallback ?? onModified);
}
}
// Button to toggle between text edit mode.
m_PickerState.manualPathEditMode = GUI.Toggle(editButtonRect, m_PickerState.manualPathEditMode, "T",
EditorStyles.miniButton);
}
private void ShowDropdown(Rect rect, SerializedProperty serializedProperty, Action modifiedCallback)
{
InputActionsEditorSettingsProvider.SetIMGUIDropdownVisible(true, false);
IsShowingDropdown = true;
if (m_PickerDropdown == null)
{
m_PickerDropdown = new InputControlPickerDropdown(
m_PickerState,
path =>
{
serializedProperty.stringValue = path;
m_PickerState.manualPathEditMode = false;
modifiedCallback();
});
}
m_PickerDropdown.SetPickedCallback(path =>
{
serializedProperty.stringValue = path;
m_PickerState.manualPathEditMode = false;
modifiedCallback();
});
m_PickerDropdown.SetControlPathsToMatch(m_ControlPathsToMatch);
m_PickerDropdown.SetExpectedControlLayout(m_ExpectedControlLayout);
m_PickerDropdown.Show(rect);
IsShowingDropdown = false;
}
private void SetExpectedControlLayoutFromAttribute(SerializedProperty property)
{
var field = property.GetField();
if (field == null)
return;
var attribute = field.GetCustomAttribute<InputControlAttribute>();
if (attribute != null)
SetExpectedControlLayout(attribute.layout);
}
public SerializedProperty pathProperty { get; }
public Action onModified { get; }
private GUIContent m_PathLabel;
private string m_ExpectedControlLayout;
private string[] m_ControlPathsToMatch;
private InputControlPickerDropdown m_PickerDropdown;
private readonly InputControlPickerState m_PickerState;
/// <summary>
/// This property is only set from this class in order to communicate that we're showing the dropdown at the moment
/// It's employed to skip auto-saving, because that complicates updating the internal SerializedProperties.
/// Unfortunately, we can't use IMGUIDropdownVisible from the setings provider because of the early-out logic in there.
/// </summary>
internal static bool IsShowingDropdown { get; private set; }
}
}
#endif // UNITY_EDITOR

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 924d7617bcdc84f8ea1c0d0ce6466a30
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,41 @@
#if UNITY_EDITOR || PACKAGE_DOCS_GENERATION
using System;
////REVIEW: should this be a PopupWindowContent?
namespace UnityEngine.InputSystem.Editor
{
/// <summary>
/// A popup that allows picking input controls graphically.
/// </summary>
public sealed class InputControlPicker : IDisposable
{
public InputControlPicker(Mode mode, Action<string> onPick, InputControlPickerState state)
{
m_State = state ?? new InputControlPickerState();
m_Dropdown = new InputControlPickerDropdown(state, onPick, mode: mode);
}
public void Show(Rect rect)
{
m_Dropdown.Show(rect);
}
public void Dispose()
{
m_Dropdown?.Dispose();
}
public InputControlPickerState state => m_State;
private readonly InputControlPickerDropdown m_Dropdown;
private readonly InputControlPickerState m_State;
public enum Mode
{
PickControl,
PickDevice,
}
}
}
#endif // UNITY_EDITOR

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e35c175d4f60c4820a36054488cb5524
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,645 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine.InputSystem.Controls;
using UnityEngine.InputSystem.Layouts;
using UnityEngine.InputSystem.Utilities;
////TODO: have tooltips on each entry in the picker
////TODO: find better way to present controls when filtering to specific devices
////REVIEW: if there's only a single device in the picker, automatically go into it?
namespace UnityEngine.InputSystem.Editor
{
internal class InputControlPickerDropdown : AdvancedDropdown, IDisposable
{
public InputControlPickerDropdown(
InputControlPickerState state,
Action<string> onPickCallback,
InputControlPicker.Mode mode = InputControlPicker.Mode.PickControl)
: base(state.advancedDropdownState)
{
m_Gui = new InputControlPickerGUI(this);
minimumSize = new Vector2(275, 300);
maximumSize = new Vector2(0, 300);
m_OnPickCallback = onPickCallback;
m_Mode = mode;
}
public void SetControlPathsToMatch(string[] controlPathsToMatch)
{
m_ControlPathsToMatch = controlPathsToMatch;
Reload();
}
public void SetExpectedControlLayout(string expectedControlLayout)
{
m_ExpectedControlLayout = expectedControlLayout;
if (string.Equals(expectedControlLayout, "InputDevice", StringComparison.InvariantCultureIgnoreCase))
m_ExpectedControlType = typeof(InputDevice);
else
m_ExpectedControlType = !string.IsNullOrEmpty(expectedControlLayout)
? InputSystem.s_Manager.m_Layouts.GetControlTypeForLayout(new InternedString(expectedControlLayout))
: null;
// If the layout is for a device, automatically switch to device
// picking mode.
if (m_ExpectedControlType != null && typeof(InputDevice).IsAssignableFrom(m_ExpectedControlType))
m_Mode = InputControlPicker.Mode.PickDevice;
Reload();
}
public void SetPickedCallback(Action<string> action)
{
m_OnPickCallback = action;
}
protected override void OnDestroy()
{
InputActionsEditorSettingsProvider.SetIMGUIDropdownVisible(false, false);
m_RebindingOperation?.Dispose();
m_RebindingOperation = null;
}
public void Dispose()
{
m_RebindingOperation?.Dispose();
}
protected override AdvancedDropdownItem BuildRoot()
{
var root = new AdvancedDropdownItem(string.Empty);
// Usages.
if (m_Mode != InputControlPicker.Mode.PickDevice)
{
var usages = BuildTreeForControlUsages();
if (usages.children.Any())
{
root.AddChild(usages);
root.AddSeparator();
}
}
// Devices.
AddItemsForDevices(root);
return root;
}
protected override AdvancedDropdownItem BuildCustomSearch(string searchString,
IEnumerable<AdvancedDropdownItem> elements)
{
if (!isListening)
return null;
var root = new AdvancedDropdownItem(!string.IsNullOrEmpty(m_ExpectedControlLayout)
? $"Listening for {m_ExpectedControlLayout}..."
: "Listening for input...");
if (searchString == "\u0017")
return root;
var paths = searchString.Substring(1).Split('\u0017');
foreach (var element in elements)
{
if (element is ControlDropdownItem controlItem && paths.Any(x => controlItem.controlPathWithDevice == x))
root.AddChild(element);
}
return root;
}
protected override void ItemSelected(AdvancedDropdownItem item)
{
InputActionsEditorSettingsProvider.SetIMGUIDropdownVisible(false, true);
var path = ((InputControlDropdownItem)item).controlPathWithDevice;
m_OnPickCallback(path);
}
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 ControlUsageDropdownItem(device, usage, usageAndLayouts.Item1);
usageRoot.AddChild(child);
}
}
return usageRoot;
}
private void AddItemsForDevices(AdvancedDropdownItem parent)
{
// Add devices that are marked as generic types of devices directly to the parent.
// E.g. adds "Gamepad" and then underneath all the more specific types of gamepads.
foreach (var deviceLayout in EditorInputControlLayoutCache.allLayouts
.Where(x => x.isDeviceLayout && !x.isOverride && x.isGenericTypeOfDevice && !x.hideInUI)
.OrderBy(a => a.displayName))
{
AddDeviceTreeItemRecursive(deviceLayout, parent);
}
// We have devices that are based directly on InputDevice but are not marked as generic types
// of devices (e.g. Vive Lighthouses). We do not want them to clutter the list at the root so we
// put all of them in a group called "Other" at the end of the list.
var otherGroup = new AdvancedDropdownItem("Other");
foreach (var deviceLayout in EditorInputControlLayoutCache.allLayouts
.Where(x => x.isDeviceLayout && !x.isOverride && !x.isGenericTypeOfDevice &&
(x.type.BaseType == typeof(InputDevice) || x.type == typeof(InputDevice)) &&
!x.hideInUI && !x.baseLayouts.Any()).OrderBy(a => a.displayName))
{
AddDeviceTreeItemRecursive(deviceLayout, otherGroup);
}
if (otherGroup.children.Any())
parent.AddChild(otherGroup);
}
private void AddDeviceTreeItemRecursive(InputControlLayout layout, AdvancedDropdownItem parent, bool searchable = true)
{
// Find all layouts directly based on this one (ignoring overrides).
var childLayouts = EditorInputControlLayoutCache.allLayouts
.Where(x => x.isDeviceLayout && !x.isOverride && !x.hideInUI && x.baseLayouts.Contains(layout.name)).OrderBy(x => x.displayName);
// See if the entire tree should be excluded.
var shouldIncludeDeviceLayout = ShouldIncludeDeviceLayout(layout);
var shouldIncludeAtLeastOneChildLayout = childLayouts.Any(ShouldIncludeDeviceLayout);
if (!shouldIncludeDeviceLayout && !shouldIncludeAtLeastOneChildLayout)
return;
// Add toplevel item for device.
var deviceItem = new DeviceDropdownItem(layout, searchable: searchable);
var defaultControlPickerLayout = new DefaultInputControlPickerLayout();
// 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);
}
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)
{
// The keyboard is special in that we want to allow binding by display name (i.e. character
// generated by a key) instead of only by physical key location. Also, we want to give an indication
// of which specific key an entry refers to by taking the current keyboard layout into account.
//
// So what we do is add an extra level to the keyboard where key's can be bound by character
// according to the current layout. And in the top level of the keyboard we display keys with
// both physical and logical names.
if (layout.type == typeof(Keyboard) && InputSystem.GetDevice<Keyboard>() != null)
{
var byLocationGroup = new AdvancedDropdownItem("By Location of Key (Using US Layout)");
var byCharacterGroup = new AdvancedDropdownItem("By Character Mapped to Key");
deviceItem.AddChild(byLocationGroup);
deviceItem.AddChild(byCharacterGroup);
var keyboard = InputSystem.GetDevice<Keyboard>();
AddCharacterKeyBindingsTo(byCharacterGroup, keyboard);
AddPhysicalKeyBindingsTo(byLocationGroup, keyboard, searchable);
// AnyKey won't appear in either group. Add it explicitly.
AddControlItem(defaultControlPickerLayout, deviceItem, null,
layout.FindControl(new InternedString("anyKey")).Value, layout.name, null, searchable);
}
else if (layout.type == typeof(Touchscreen))
{
AddControlTreeItemsRecursive(new TouchscreenControlPickerLayout(), layout, deviceItem, layout.name, null, searchable);
}
else
{
AddControlTreeItemsRecursive(defaultControlPickerLayout, layout, deviceItem, layout.name, null, searchable);
}
}
// Add child items.
var isFirstChild = true;
foreach (var childLayout in childLayouts)
{
if (!ShouldIncludeDeviceLayout(childLayout))
continue;
if (isFirstChild)
deviceItem.AddSeparator("More Specific " + deviceItem.name.GetPlural());
isFirstChild = false;
AddDeviceTreeItemRecursive(childLayout, deviceItem, searchable && !childLayout.isGenericTypeOfDevice);
}
// When picking devices, it must be possible to select a device that itself has more specific types
// of devices underneath it. However in the dropdown, such a device will be a foldout and not itself
// be selectable. We solve this problem by adding an entry for the device underneath the device
// itself (e.g. "Gamepad >> Gamepad").
if (m_Mode == InputControlPicker.Mode.PickDevice && deviceItem.m_Children.Count > 0)
{
var item = new DeviceDropdownItem(layout);
deviceItem.m_Children.Insert(0, item);
}
if (deviceItem.m_Children.Count > 0 || m_Mode == InputControlPicker.Mode.PickDevice)
parent.AddChild(deviceItem);
}
private void AddControlTreeItemsRecursive(IInputControlPickerLayout controlPickerLayout, InputControlLayout layout,
DeviceDropdownItem parent, string device, string usage, bool searchable, ControlDropdownItem parentControl = null)
{
foreach (var control in layout.controls.OrderBy(a => a.name))
{
if (control.isModifyingExistingControl)
continue;
// Skip variants except the default variant and variants dictated by the layout itself.
if (!control.variants.IsEmpty() && control.variants != InputControlLayout.DefaultVariant
&& (layout.variants.IsEmpty() || !InputControlLayout.VariantsMatch(layout.variants, control.variants)))
{
continue;
}
controlPickerLayout.AddControlItem(this, parent, parentControl, control, device, usage, searchable);
}
// Add optional controls for devices.
var optionalControls = EditorInputControlLayoutCache.GetOptionalControlsForLayout(layout.name);
if (optionalControls.Any() && layout.isDeviceLayout)
{
var optionalGroup = new AdvancedDropdownItem("Optional Controls");
foreach (var optionalControl in optionalControls)
{
////FIXME: this should list children, too
////FIXME: this should handle arrays, too
if (LayoutMatchesExpectedControlLayoutFilter(optionalControl.layout))
{
var child = new OptionalControlDropdownItem(optionalControl, device, usage);
child.icon = EditorInputControlLayoutCache.GetIconForLayout(optionalControl.layout);
optionalGroup.AddChild(child);
}
}
if (optionalGroup.children.Any())
{
var deviceName = EditorInputControlLayoutCache.TryGetLayout(device).m_DisplayName ??
ObjectNames.NicifyVariableName(device);
parent.AddSeparator("Controls Present on More Specific " + deviceName.GetPlural());
parent.AddChild(optionalGroup);
}
}
}
internal void AddControlItem(IInputControlPickerLayout controlPickerLayout,
DeviceDropdownItem parent, ControlDropdownItem parentControl,
InputControlLayout.ControlItem control, string device, string usage, bool searchable,
string controlNameOverride = default)
{
var controlName = controlNameOverride ?? control.name;
// If it's an array, generate a control entry for each array element.
for (var i = 0; i < (control.isArray ? control.arraySize : 1); ++i)
{
var name = control.isArray ? controlName + i : controlName;
var displayName = !string.IsNullOrEmpty(control.displayName)
? (control.isArray ? $"{control.displayName} #{i}" : control.displayName)
: name;
var child = new ControlDropdownItem(parentControl, name, displayName,
device, usage, searchable);
child.icon = EditorInputControlLayoutCache.GetIconForLayout(control.layout);
var controlLayout = EditorInputControlLayoutCache.TryGetLayout(control.layout);
if (LayoutMatchesExpectedControlLayoutFilter(control.layout))
parent.AddChild(child);
else if (controlLayout.controls.Any(x => LayoutMatchesExpectedControlLayoutFilter(x.layout)))
{
child.enabled = false;
parent.AddChild(child);
}
// Add children.
if (controlLayout != null)
AddControlTreeItemsRecursive(controlPickerLayout, controlLayout, parent, device, usage,
searchable, child);
}
}
private static void AddPhysicalKeyBindingsTo(AdvancedDropdownItem parent, Keyboard keyboard, bool searchable)
{
foreach (var key in keyboard.children.OfType<KeyControl>())
{
// If the key has a display name that differs from the key name, show it in the UI.
var displayName = key.m_DisplayNameFromLayout;
var keyDisplayName = key.displayName;
if (keyDisplayName.All(x => x.IsPrintable()) && string.Compare(keyDisplayName, displayName,
StringComparison.InvariantCultureIgnoreCase) != 0)
displayName = $"{displayName} (Current Layout: {key.displayName})";
// For left/right modifier keys, prepend artificial combined version.
ButtonControl combinedVersion = null;
if (key == keyboard.leftShiftKey)
combinedVersion = keyboard.shiftKey;
else if (key == keyboard.leftAltKey)
combinedVersion = keyboard.altKey;
else if (key == keyboard.leftCtrlKey)
combinedVersion = keyboard.ctrlKey;
if (combinedVersion != null)
parent.AddChild(new ControlDropdownItem(null, combinedVersion.name, combinedVersion.displayName, keyboard.layout,
"", searchable));
var item = new ControlDropdownItem(null, key.name, displayName,
keyboard.layout, "", searchable);
parent.AddChild(item);
}
}
private static void AddCharacterKeyBindingsTo(AdvancedDropdownItem parent, Keyboard keyboard)
{
foreach (var key in keyboard.children.OfType<KeyControl>())
{
if (!key.keyCode.IsTextInputKey())
continue;
// We can only bind to characters that can be printed.
var displayName = key.displayName;
if (!displayName.All(x => x.IsPrintable()))
continue;
if (displayName.Contains(')'))
displayName = string.Join("", displayName.Select(x => "\\" + x));
////TODO: should be searchable; when searching, needs different display name
var item = new ControlDropdownItem(null, $"#({displayName})", "", keyboard.layout, "", false);
item.name = key.displayName;
parent.AddChild(item);
}
}
private bool LayoutMatchesExpectedControlLayoutFilter(string layout)
{
if (m_ExpectedControlType == null)
return true;
var layoutType = InputSystem.s_Manager.m_Layouts.GetControlTypeForLayout(new InternedString(layout));
return m_ExpectedControlType.IsAssignableFrom(layoutType);
}
private bool ShouldIncludeDeviceLayout(InputControlLayout layout)
{
if (layout.hideInUI)
return false;
// By default, if a device has no (usable) controls, we don't want it listed in the control picker
// except if we're picking devices.
if (!layout.controls.Any(x => LayoutMatchesExpectedControlLayoutFilter(x.layout)) && layout.controls.Any(x => true) &&
m_Mode != InputControlPicker.Mode.PickDevice)
return false;
// If we have a device filter, see if we should ignore the device.
if (m_ControlPathsToMatch != null && m_ControlPathsToMatch.Length > 0)
{
var matchesAnyInDeviceFilter = false;
foreach (var entry in m_ControlPathsToMatch)
{
// Include the layout if it's in the inheritance hierarchy of the layout we expect (either below
// or above it or, well, just right on it).
var expectedLayout = InputControlPath.TryGetDeviceLayout(entry);
if (!string.IsNullOrEmpty(expectedLayout) &&
(expectedLayout == layout.name ||
InputControlLayout.s_Layouts.IsBasedOn(layout.name, new InternedString(expectedLayout)) ||
InputControlLayout.s_Layouts.IsBasedOn(new InternedString(expectedLayout), layout.name)))
{
matchesAnyInDeviceFilter = true;
break;
}
}
if (!matchesAnyInDeviceFilter)
return false;
}
return true;
}
private void StartListening()
{
if (m_RebindingOperation == null)
m_RebindingOperation = new InputActionRebindingExtensions.RebindingOperation();
////TODO: for keyboard, generate both possible paths (physical and by display name)
m_RebindingOperation.Reset();
m_RebindingOperation
.WithExpectedControlType(m_ExpectedControlLayout)
// Require minimum actuation of 0.15f. This is after deadzoning has been applied.
.WithMagnitudeHavingToBeGreaterThan(0.15f)
////REVIEW: should we exclude only the system's active pointing device?
// With the mouse operating the UI, its cursor control is too fickle a thing to
// bind to. Ignore mouse position and delta and clicks.
// NOTE: We go for all types of pointers here, not just mice.
.WithControlsExcluding("<Pointer>/position")
.WithControlsExcluding("<Pointer>/delta")
.WithControlsExcluding("<Pointer>/press")
.WithControlsExcluding("<Pointer>/clickCount")
.WithControlsExcluding("<Pointer>/{PrimaryAction}")
.WithControlsExcluding("<Mouse>/scroll")
.OnPotentialMatch(
operation =>
{
// We never really complete the pick but keep listening for as long as the "Interactive"
// button is toggled on.
Repaint();
})
.OnCancel(
operation =>
{
Repaint();
})
.OnApplyBinding(
(operation, newPath) =>
{
// This is never invoked (because we don't complete the pick) but we need it nevertheless
// as RebindingOperation requires the callback if we don't supply an action to apply the binding to.
});
// If we have control paths to match, pass them on.
if (m_ControlPathsToMatch.LengthSafe() > 0)
m_ControlPathsToMatch.Select(x => m_RebindingOperation.WithControlsHavingToMatchPath(x));
m_RebindingOperation.Start();
}
private void StopListening()
{
m_RebindingOperation?.Cancel();
}
// This differs from RebindingOperation.GeneratePathForControl in that it cycles through all
// layouts in the inheritance chain and generates a path for each one that contains the given control.
private static IEnumerable<string> GeneratePossiblePathsForControl(InputControl control)
{
var builder = new StringBuilder();
var deviceLayoutName = control.device.m_Layout;
do
{
// Skip layout if it is supposed to be hidden in the UI.
var layout = EditorInputControlLayoutCache.TryGetLayout(deviceLayoutName);
if (layout.hideInUI)
continue;
builder.Length = 0;
yield return control.BuildPath(deviceLayoutName, builder);
}
while (InputControlLayout.s_Layouts.baseLayoutTable.TryGetValue(deviceLayoutName, out deviceLayoutName));
}
private Action<string> m_OnPickCallback;
private InputControlPicker.Mode m_Mode;
private string[] m_ControlPathsToMatch;
private string m_ExpectedControlLayout;
private Type m_ExpectedControlType;
private InputActionRebindingExtensions.RebindingOperation m_RebindingOperation;
private bool isListening => m_RebindingOperation != null && m_RebindingOperation.started;
private class InputControlPickerGUI : AdvancedDropdownGUI
{
private readonly InputControlPickerDropdown m_Owner;
public InputControlPickerGUI(InputControlPickerDropdown owner)
{
m_Owner = owner;
}
internal override void BeginDraw(EditorWindow window)
{
if (Event.current.isKey && Event.current.keyCode == KeyCode.Escape)
{
window.Close();
return;
}
if (m_Owner.isListening)
{
// Eat key events to suppress the editor from passing them to the OS
// (causing beeps or menu commands being triggered).
if (Event.current.isKey)
Event.current.Use();
}
}
internal override string DrawSearchFieldControl(string searchString)
{
using (new EditorGUILayout.HorizontalScope())
{
var isListening = false;
// When picking controls, have a "Listen" button that allows listening for input.
if (m_Owner.m_Mode == InputControlPicker.Mode.PickControl)
{
using (new EditorGUILayout.VerticalScope(GUILayout.MaxWidth(50)))
{
GUILayout.Space(4);
var isListeningOld = m_Owner.isListening;
var isListeningNew = GUILayout.Toggle(isListeningOld, "Listen",
EditorStyles.miniButton, GUILayout.MaxWidth(50));
if (isListeningOld != isListeningNew)
{
if (isListeningNew)
{
m_Owner.StartListening();
}
else
{
m_Owner.StopListening();
searchString = string.Empty;
}
}
isListening = isListeningNew;
}
}
////FIXME: the search box doesn't clear out when listening; no idea why the new string isn't taking effect
EditorGUI.BeginDisabledGroup(isListening);
var newSearchString = base.DrawSearchFieldControl(isListening ? string.Empty : searchString);
EditorGUI.EndDisabledGroup();
if (isListening)
{
var rebind = m_Owner.m_RebindingOperation;
return "\u0017" + string.Join("\u0017",
rebind.candidates.SelectMany(x => GeneratePossiblePathsForControl(x).Reverse()));
}
return newSearchString;
}
}
internal override void DrawItem(AdvancedDropdownItem item, string name, Texture2D icon, bool enabled,
bool drawArrow, bool selected, bool hasSearch, bool richText = false)
{
if (hasSearch && item is InputControlDropdownItem viewItem)
name = viewItem.searchableName;
base.DrawItem(item, name, icon, enabled, drawArrow, selected, hasSearch);
}
internal override void DrawFooter(AdvancedDropdownItem selectedItem)
{
//dun work because there is no selection
if (selectedItem is ControlDropdownItem controlItem)
{
var content = new GUIContent(controlItem.controlPath);
var rect = GUILayoutUtility.GetRect(content, headerStyle, GUILayout.ExpandWidth(true));
EditorGUI.TextField(rect, controlItem.controlPath, headerStyle);
}
}
}
private static class Styles
{
public static readonly GUIStyle waitingForInputLabel = new GUIStyle("WhiteBoldLabel").WithFontSize(22);
}
}
}
#endif // UNITY_EDITOR

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db7a158b580fb4518ae82aa029a54892
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,27 @@
#if UNITY_EDITOR || PACKAGE_DOCS_GENERATION
using System;
namespace UnityEngine.InputSystem.Editor
{
/// <summary>
/// Persistent state for <see cref="InputControlPathEditor"/>.
/// </summary>
/// <remarks>
/// This class encapsulates the viewing state for an input control picker.
/// </remarks>
[Serializable]
public class InputControlPickerState
{
internal AdvancedDropdownState advancedDropdownState => m_AdvancedDropdownState;
internal bool manualPathEditMode
{
get => m_ManualPathEditMode;
set => m_ManualPathEditMode = value;
}
[SerializeField] private AdvancedDropdownState m_AdvancedDropdownState = new AdvancedDropdownState();
[SerializeField] private bool m_ManualPathEditMode;
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dfa8add0e3ed349708aeadc8b8c1ad0f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5b630b769a2a8aa499ce2d2093f40500
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
#if UNITY_EDITOR
using UnityEngine.InputSystem.Layouts;
namespace UnityEngine.InputSystem.Editor
{
internal class DefaultInputControlPickerLayout : IInputControlPickerLayout
{
public void AddControlItem(InputControlPickerDropdown dropdown, DeviceDropdownItem parent,
ControlDropdownItem parentControl,
InputControlLayout.ControlItem control, string device, string usage, bool searchable)
{
dropdown.AddControlItem(this, parent, parentControl, control, device, usage, searchable);
}
}
}
#endif // UNITY_EDITOR

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7717bf968da90024b9c047ba1a3394ec
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,36 @@
#if UNITY_EDITOR
using UnityEngine.InputSystem.Layouts;
using UnityEngine.InputSystem.Utilities;
namespace UnityEngine.InputSystem.Editor
{
internal class TouchscreenControlPickerLayout : IInputControlPickerLayout
{
public void AddControlItem(InputControlPickerDropdown dropdown, DeviceDropdownItem parent, ControlDropdownItem parentControl,
InputControlLayout.ControlItem control, string device, string usage, bool searchable)
{
// for the Press control, show two variants, one for single touch presses, and another for multi-touch presses
if (control.displayName == "Press")
{
dropdown.AddControlItem(this, parent, parentControl, new InputControlLayout.ControlItem
{
name = new InternedString("Press"),
displayName = new InternedString("Press (Single touch)"),
layout = control.layout
}, device, usage, searchable);
dropdown.AddControlItem(this, parent, parentControl, new InputControlLayout.ControlItem
{
name = new InternedString("Press"),
displayName = new InternedString("Press (Multi-touch)"),
layout = control.layout
}, device, usage, searchable, "touch*/Press");
}
else
{
dropdown.AddControlItem(this, parent, parentControl, control, device, usage, searchable);
}
}
}
}
#endif // UNITY_EDITOR

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 224236193350c244abab484f4514df16
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: