diff --git a/Assets/Samples/InGameHints/InGameHintsActions.cs b/Assets/Samples/InGameHints/InGameHintsActions.cs index fc21f943aa..ad0e2b99a7 100644 --- a/Assets/Samples/InGameHints/InGameHintsActions.cs +++ b/Assets/Samples/InGameHints/InGameHintsActions.cs @@ -101,7 +101,8 @@ public @InGameHintsActions() ""expectedControlType"": ""Vector2"", ""processors"": """", ""interactions"": """", - ""initialStateCheck"": true + ""initialStateCheck"": true, + ""priority"": 0 }, { ""name"": ""Look"", @@ -110,7 +111,8 @@ public @InGameHintsActions() ""expectedControlType"": ""Vector2"", ""processors"": """", ""interactions"": """", - ""initialStateCheck"": true + ""initialStateCheck"": true, + ""priority"": 0 }, { ""name"": ""PickUp"", @@ -119,7 +121,8 @@ public @InGameHintsActions() ""expectedControlType"": """", ""processors"": """", ""interactions"": """", - ""initialStateCheck"": false + ""initialStateCheck"": false, + ""priority"": 0 }, { ""name"": ""Drop"", @@ -128,7 +131,8 @@ public @InGameHintsActions() ""expectedControlType"": """", ""processors"": """", ""interactions"": """", - ""initialStateCheck"": false + ""initialStateCheck"": false, + ""priority"": 0 }, { ""name"": ""Throw"", @@ -137,7 +141,8 @@ public @InGameHintsActions() ""expectedControlType"": """", ""processors"": """", ""interactions"": """", - ""initialStateCheck"": false + ""initialStateCheck"": false, + ""priority"": 0 } ], ""bindings"": [ diff --git a/Assets/Samples/SimpleDemo/SimpleControls.cs b/Assets/Samples/SimpleDemo/SimpleControls.cs index 6bacf030df..1aaf0dcc73 100644 --- a/Assets/Samples/SimpleDemo/SimpleControls.cs +++ b/Assets/Samples/SimpleDemo/SimpleControls.cs @@ -99,7 +99,8 @@ public @SimpleControls() ""expectedControlType"": ""Button"", ""processors"": """", ""interactions"": ""Tap,SlowTap"", - ""initialStateCheck"": false + ""initialStateCheck"": false, + ""priority"": 0 }, { ""name"": ""move"", @@ -108,7 +109,8 @@ public @SimpleControls() ""expectedControlType"": ""Vector2"", ""processors"": """", ""interactions"": """", - ""initialStateCheck"": true + ""initialStateCheck"": true, + ""priority"": 0 }, { ""name"": ""look"", @@ -117,7 +119,8 @@ public @SimpleControls() ""expectedControlType"": ""Vector2"", ""processors"": """", ""interactions"": """", - ""initialStateCheck"": true + ""initialStateCheck"": true, + ""priority"": 0 } ], ""bindings"": [ diff --git a/Assets/Tests/InputSystem/CoreTests_Actions.cs b/Assets/Tests/InputSystem/CoreTests_Actions.cs index bcec243f2e..260bb37de3 100644 --- a/Assets/Tests/InputSystem/CoreTests_Actions.cs +++ b/Assets/Tests/InputSystem/CoreTests_Actions.cs @@ -232,13 +232,16 @@ public void Actions_WhenShortcutsEnabled_CanConsumeInput(bool legacyComposites) action2.Enable(); // State monitors on the space key (all end up in the same group): - // action3 complexity=3 - // action2 complexity=2 - // action1 complexity=1 - // action4 complexity=1 - // action5 complexity=1 + action3.Priority = 3; + action2.Priority = 2; + action1.Priority = 1; + action4.Priority = 1; + action5.Priority = 1; action1.AddBinding("/space"); + // Ordered modifier evaluation: modifier must be pressed before the button for this chord shape. + // With shortcutKeysUseActionPriority on, IsShortcutComplexityModifierOrderActive is false, so + // Default would resolve to Unordered and pressing space then shift would still satisfy the composite. action2.AddCompositeBinding(legacyComposites ? "ButtonWithOneModifier" : "OneModifier") .With("Modifier", "/shift") .With(legacyComposites ? "Button" : "Binding", "/space"); @@ -303,6 +306,7 @@ public void Actions_WhenShortcutsEnabled_CanConsumeInput(bool legacyComposites) public void Actions_ShortcutSupportDisabledByDefault() { Assert.That(InputSystem.settings.shortcutKeysConsumeInput, Is.False); + Assert.That(InputSystem.settings.shortcutKeysUseActionPriority, Is.False); var keyboard = InputSystem.AddDevice(); @@ -324,6 +328,35 @@ public void Actions_ShortcutSupportDisabledByDefault() Assert.That(action2.WasPerformedThisFrame(), Is.True); } + [Test] + [Category("Actions")] + public void InputSettings_ShortcutResolutionModeHelpers_MatchExpectedMatrix() + { + InputSystem.settings.shortcutKeysConsumeInput = false; + InputSystem.settings.shortcutKeysUseActionPriority = false; + Assert.That(InputSystem.settings.IsShortcutResolutionUsingActionPriority, Is.False); + Assert.That(InputSystem.settings.IsShortcutResolutionUsingComplexity, Is.False); + Assert.That(InputSystem.settings.IsShortcutComplexityModifierOrderActive, Is.False); + + InputSystem.settings.shortcutKeysConsumeInput = true; + InputSystem.settings.shortcutKeysUseActionPriority = false; + Assert.That(InputSystem.settings.IsShortcutResolutionUsingActionPriority, Is.False); + Assert.That(InputSystem.settings.IsShortcutResolutionUsingComplexity, Is.True); + Assert.That(InputSystem.settings.IsShortcutComplexityModifierOrderActive, Is.True); + + InputSystem.settings.shortcutKeysConsumeInput = false; + InputSystem.settings.shortcutKeysUseActionPriority = true; + Assert.That(InputSystem.settings.IsShortcutResolutionUsingActionPriority, Is.True); + Assert.That(InputSystem.settings.IsShortcutResolutionUsingComplexity, Is.False); + Assert.That(InputSystem.settings.IsShortcutComplexityModifierOrderActive, Is.False); + + InputSystem.settings.shortcutKeysConsumeInput = true; + InputSystem.settings.shortcutKeysUseActionPriority = true; + Assert.That(InputSystem.settings.IsShortcutResolutionUsingActionPriority, Is.True); + Assert.That(InputSystem.settings.IsShortcutResolutionUsingComplexity, Is.False); + Assert.That(InputSystem.settings.IsShortcutComplexityModifierOrderActive, Is.False); + } + [Test] [Category("Actions")] public void Actions_CanBindShortcutsInvolvingMultipleDevices() @@ -472,8 +505,7 @@ public void Actions_WhenShortcutsDisabled_PressingShortcutSequenceInWrongOrder_D [TestCase("leftShift", "leftAlt", "space", true, false)] [TestCase("leftShift", null, "space", false, false)] [TestCase("leftShift", "leftAlt", "space", false, false)] - public void - Actions_WhenShortcutsAreEnabled_PressingShortcutSequenceInWrongOrder_DoesNotTriggerShortcut_ExceptIfOverridden(string modifier1, string modifier2, string binding, bool legacyComposites, bool overrideModifiersNeedToBePressedFirst) + public void Actions_WhenShortcutsAreEnabled_PressingShortcutSequenceInWrongOrder_DoesNotTriggerShortcut_ExceptIfOverridden(string modifier1, string modifier2, string binding, bool legacyComposites, bool overrideModifiersNeedToBePressedFirst) { InputSystem.settings.shortcutKeysConsumeInput = true; @@ -514,6 +546,7 @@ public void public void Actions_WhenShortcutsAreEnabled_CanHaveShortcutsWithButtonsUsingInitialStateChecks() { InputSystem.settings.shortcutKeysConsumeInput = true; + InputSystem.settings.shortcutKeysUseActionPriority = true; var keyboard = InputSystem.AddDevice(); @@ -525,6 +558,9 @@ public void Actions_WhenShortcutsAreEnabled_CanHaveShortcutsWithButtonsUsingInit .With("Modifier", "/shift") .With("Binding", "/space"); + // We now need to set priority for this test to act as it used to with complexity. + action2.Priority = 1; + action1.wantsInitialStateCheck = true; action2.wantsInitialStateCheck = true; @@ -1635,14 +1671,14 @@ public void Actions_ActiveBindingsHaveCorrectBindingIndicesAfterBindingResolutio // with the control (i.e. mouse.leftButton) or with action callbacks // could all appear correct because those don't actually use bindingIndex. // This issue originally manifested itself as an assert in another place in the code. - InputSystem.RegisterProcessor(); + InputSystem.RegisterProcessor(); // This test is sensitive to binding order. // It's important that the active binding is not in the first // position of the action (i.e. not at the default index). var map = new InputActionMap("map"); var action = map.AddAction("action1", binding: "/buttonSouth"); - action.AddBinding("/leftButton").WithProcessor(); // binding in 2nd position. + action.AddBinding("/leftButton").WithProcessor(); // binding in 2nd position. map.Enable(); var mouse = InputSystem.AddDevice(); @@ -1717,6 +1753,7 @@ public void Actions_CanDisableAndEnable_FromCallbackWhileOtherCompositeBindingIs { // Enables "Modifier must be pressed first" behavior on all Composite Bindings InputSystem.settings.shortcutKeysConsumeInput = true; + InputSystem.settings.shortcutKeysUseActionPriority = true; var keyboard = InputSystem.AddDevice(); var map = new InputActionMap("map"); @@ -1727,6 +1764,8 @@ public void Actions_CanDisableAndEnable_FromCallbackWhileOtherCompositeBindingIs .With("Binding", "/space") .With("Modifier", "/ctrl"); actionWithModifier.performed += _ => ++ withModiferReceivedCalls; + actionWithModifier.Priority = 1; + var actionWithoutModifier = map.AddAction("One", type: InputActionType.Button, binding: "/space"); actionWithoutModifier.performed += _ => actionWithModifier.Disable(); @@ -4319,7 +4358,7 @@ public void Actions_WithMultipleBoundControls_CanHandleInteractionsThatTriggerOn var keyboard = InputSystem.AddDevice(); var gamepad = InputSystem.AddDevice(); - InputSystem.RegisterInteraction(); + InputSystem.RegisterInteraction(); var action = new InputAction(interactions: "releaseOnlyTest"); @@ -5571,28 +5610,13 @@ public ModificationCases() private static readonly Modification[] ModificationAppliesToSingleActionMap = { - Modification.AddBinding, - Modification.RemoveBinding, - Modification.ModifyBinding, - Modification.ApplyBindingOverride, - Modification.AddAction, - Modification.RemoveAction, - Modification.ChangeBindingMask, - Modification.AddDevice, - Modification.RemoveDevice, - Modification.AddDeviceGlobally, - Modification.RemoveDeviceGlobally, + CoreTests.Modification.AddBinding, CoreTests.Modification.RemoveBinding, CoreTests.Modification.ModifyBinding, CoreTests.Modification.ApplyBindingOverride, CoreTests.Modification.AddAction, CoreTests.Modification.RemoveAction, CoreTests.Modification.ChangeBindingMask, CoreTests.Modification.AddDevice, CoreTests.Modification.RemoveDevice, CoreTests.Modification.AddDeviceGlobally, CoreTests.Modification.RemoveDeviceGlobally, // Excludes: AddMap, RemoveMap }; private static readonly Modification[] ModificationAppliesToSingletonAction = { - Modification.AddBinding, - Modification.RemoveBinding, - Modification.ModifyBinding, - Modification.ApplyBindingOverride, - Modification.AddDeviceGlobally, - Modification.RemoveDeviceGlobally, + CoreTests.Modification.AddBinding, CoreTests.Modification.RemoveBinding, CoreTests.Modification.ModifyBinding, CoreTests.Modification.ApplyBindingOverride, CoreTests.Modification.AddDeviceGlobally, CoreTests.Modification.RemoveDeviceGlobally, }; public IEnumerator GetEnumerator() @@ -5660,7 +5684,7 @@ private InputActionMap CreateSingletonAction() [Test] [Category("Actions")] - [TestCaseSource(typeof(ModificationCases))] + [TestCaseSource(typeof(CoreTests.ModificationCases))] public void Actions_CanHandleModification(Modification modification, Func getActions) { // Exclude project-wide actions from this test @@ -6230,7 +6254,7 @@ public void Actions_CanAddProcessorsToActions() { var gamepad = InputSystem.AddDevice(); - InputSystem.RegisterProcessor(); + InputSystem.RegisterProcessor(); var action = new InputAction(processors: "ConstantVector2Test"); action.AddBinding("/leftStick"); action.Enable(); @@ -6256,7 +6280,7 @@ public void Actions_IncompatibleProcessorIsIgnored() { var gamepad = InputSystem.AddDevice(); - InputSystem.RegisterProcessor(); + InputSystem.RegisterProcessor(); var action = new InputAction(processors: "ConstantVector2Test"); action.AddBinding("/leftStick/x"); action.Enable(); @@ -6292,9 +6316,9 @@ public void Actions_CanAddProcessorsToBindings() { var gamepad = InputSystem.AddDevice(); - InputSystem.RegisterProcessor(); + InputSystem.RegisterProcessor(); var action = new InputAction(); - action.AddBinding("/leftStick").WithProcessor(); + action.AddBinding("/leftStick").WithProcessor(); action.Enable(); Vector2? receivedVector = null; @@ -6403,13 +6427,13 @@ public override float Process(float value, InputControl control) [Category("Actions")] public void Actions_AddingSameProcessorTwice_DoesntImpactUIHideState() { - InputSystem.RegisterProcessor(); + InputSystem.RegisterProcessor(); Assert.That(InputSystem.TryGetProcessor("ConstantFloat1Test"), Is.Not.EqualTo(null)); bool hide = InputSystem.manager.processors.ShouldHideInUI("ConstantFloat1Test"); Assert.That(hide, Is.EqualTo(false)); - InputSystem.RegisterProcessor(); + InputSystem.RegisterProcessor(); // Check we haven't caused this to alias with itself and cause it to be hidden in the UI hide = InputSystem.manager.processors.ShouldHideInUI("ConstantFloat1Test"); Assert.That(hide, Is.EqualTo(false)); @@ -6423,8 +6447,8 @@ public void Actions_AddingSameNamedProcessorWithDifferentResult_OverridesOrigina { var gamepad = InputSystem.AddDevice(); - InputSystem.RegisterProcessor("ConstantFloatTest"); - InputSystem.RegisterProcessor("ConstantFloatTest"); + InputSystem.RegisterProcessor("ConstantFloatTest"); + InputSystem.RegisterProcessor("ConstantFloatTest"); var action = new InputAction(processors: "ConstantFloatTest"); action.AddBinding("/leftTrigger"); @@ -7027,7 +7051,7 @@ public void Reset() [Category("Actions")] public void Actions_CanRegisterNewInteraction() { - InputSystem.RegisterInteraction(); + InputSystem.RegisterInteraction(); TestInteraction.s_GotInvoked = false; var gamepad = InputSystem.AddDevice("Gamepad"); @@ -9422,7 +9446,7 @@ private class CompositeWithParameters : InputBindingComposite public bool boolParameter; public EnumParameter enumParameter; - public static CompositeWithParameters s_Instance; + public static CoreTests.CompositeWithParameters s_Instance; public CompositeWithParameters() { @@ -9451,7 +9475,7 @@ public override float EvaluateMagnitude(ref InputBindingCompositeContext context [Category("Actions")] public void Actions_CanHaveParametersOnComposites() { - InputSystem.RegisterBindingComposite(); + InputSystem.RegisterBindingComposite(); // NOTE: Enums aren't supported at the JSON level. The editor uses reflection to display textual names rather // than plain integer values but underneath, enums are treated as ints. @@ -10130,7 +10154,7 @@ public void Reset() public void Actions_Vector2Composite_TriggersActionOnlyOnceWhenMultipleComponentBindingsTriggerInSingleEvent() { var keyboard = InputSystem.AddDevice(); - InputSystem.RegisterInteraction(); + InputSystem.RegisterInteraction(); var action = new InputAction(); action.AddCompositeBinding("Dpad", interactions: "log") @@ -10400,7 +10424,7 @@ public void Actions_WithMultipleComposites_CancelsIfCompositeIsReleased() var keyboard = InputSystem.AddDevice(); var gamepad = InputSystem.AddDevice(); - InputSystem.RegisterInteraction(); + InputSystem.RegisterInteraction(); var action = new InputAction(); action.AddCompositeBinding("Dpad(normalize=0)") @@ -10484,7 +10508,7 @@ public void Actions_CompositesReportControlThatTriggeredTheCompositeInCallback() var keyboard = InputSystem.AddDevice(); var gamepad = InputSystem.AddDevice(); - InputSystem.RegisterInteraction(); + InputSystem.RegisterInteraction(); var action = new InputAction(); action.AddBinding("/leftStick"); @@ -10541,7 +10565,7 @@ public void Actions_CompositesInDifferentMapsTiedToSameControlsWork() var keyboard = InputSystem.AddDevice(); var gamepad = InputSystem.AddDevice(); - InputSystem.RegisterInteraction(); + InputSystem.RegisterInteraction(); var map1 = new InputActionMap("map1"); var action1 = map1.AddAction("action"); @@ -10610,7 +10634,7 @@ public override Vector2 ReadValue(ref InputBindingCompositeContext context) [Category("Actions")] public void Actions_CanCreateCompositeWithVector2PartBinding() { - InputSystem.RegisterBindingComposite(); + InputSystem.RegisterBindingComposite(); var gamepad = InputSystem.AddDevice(); var action = new InputAction(); @@ -10644,7 +10668,7 @@ public override float ReadValue(ref InputBindingCompositeContext context) [Category("Actions")] public void Actions_CanGetSourceControlWhenReadingValueFromCompositePart() { - InputSystem.RegisterBindingComposite(); + InputSystem.RegisterBindingComposite(); var gamepad = InputSystem.AddDevice(); var action = new InputAction(); @@ -11634,8 +11658,9 @@ public void Actions_DisablingAllActions_RemovesAllTheirStateMonitors() // Not the most elegant test as we reach into internals here but with the // current API, it's not possible to enumerate monitors from outside. - Assert.That(InputSystem.manager.m_StateChangeMonitors, - Has.All.Matches((InputManager.StateChangeMonitorsForDevice x) => x.memoryRegions.All(r => r.sizeInBits == 0))); + Assert.That(InputSystem.manager.m_StateMonitors.m_MonitorsPerDevice, + Has.All.Matches( + (InputManagerStateMonitors.StateChangeMonitorsForDevice x) => x.memoryRegions.All(r => r.sizeInBits == 0))); } // https://fogbugz.unity3d.com/f/cases/1367442/ @@ -11799,7 +11824,7 @@ public void Reset() [Category("Actions")] public void Actions_InteractionContextRespectsCustomDefaultStates() { - InputSystem.RegisterInteraction(); + InputSystem.RegisterInteraction(); const string json = @" { @@ -12277,7 +12302,7 @@ private class MonoBehaviourWithActionProperty : MonoBehaviour public void Actions_Property_CanGetAction_WithNullReferenceType() { var go = new GameObject(); - var component = go.AddComponent(); + var component = go.AddComponent(); component.actionProperty = new InputActionProperty((InputActionReference)null); Assert.DoesNotThrow(() => _ = component.actionProperty.action); @@ -12291,7 +12316,7 @@ public void Actions_Property_CanGetAction_WithNullReferenceType() public void Actions_Property_CanGetAction_WithNullActionType() { var go = new GameObject(); - var component = go.AddComponent(); + var component = go.AddComponent(); component.actionProperty = new InputActionProperty((InputAction)null); Assert.DoesNotThrow(() => _ = component.actionProperty.action); @@ -12313,7 +12338,7 @@ public void Actions_Property_CanGetAction_WithDestroyedReferenceType() reference.Set(asset, "map", "action1"); var go = new GameObject(); - var component = go.AddComponent(); + var component = go.AddComponent(); component.actionProperty = new InputActionProperty(reference); Assert.That(component.actionProperty.action, Is.Not.Null); @@ -12409,7 +12434,7 @@ public struct PointerInput public float? Twist; } - public class PointerInputComposite : InputBindingComposite + public class PointerInputComposite : InputBindingComposite { [InputControl(layout = "Button")] public int contact; @@ -12425,7 +12450,7 @@ public class PointerInputComposite : InputBindingComposite [InputControl(layout = "Integer")] public int inputId; - public override PointerInput ReadValue(ref InputBindingCompositeContext context) + public override CoreTests.PointerInput ReadValue(ref InputBindingCompositeContext context) { var contact = context.ReadValueAsButton(this.contact); var pointerId = context.ReadValue(inputId); @@ -12435,7 +12460,7 @@ public override PointerInput ReadValue(ref InputBindingCompositeContext context) var position = context.ReadValue(this.position); var twist = context.ReadValue(this.twist); - return new PointerInput + return new CoreTests.PointerInput { Contact = contact, InputId = pointerId, @@ -12455,7 +12480,7 @@ public override PointerInput ReadValue(ref InputBindingCompositeContext context) [TestCase(false)] public void Actions_WithMultipleCompositeBindings_WithoutEvaluateMagnitude_Works(bool prepopulateTouchesBeforeEnablingAction) { - InputSystem.RegisterBindingComposite(); + InputSystem.RegisterBindingComposite(); InputSystem.AddDevice(); @@ -12469,10 +12494,10 @@ public void Actions_WithMultipleCompositeBindings_WithoutEvaluateMagnitude_Works .With("pressure", $"/touch{i}/pressure") .With("inputId", $"/touch{i}/touchId"); - var values = new List(); - action.started += ctx => values.Add(ctx.ReadValue()); - action.performed += ctx => values.Add(ctx.ReadValue()); - action.canceled += ctx => values.Add(ctx.ReadValue()); + var values = new List(); + action.started += ctx => values.Add(ctx.ReadValue()); + action.performed += ctx => values.Add(ctx.ReadValue()); + action.canceled += ctx => values.Add(ctx.ReadValue()); if (!prepopulateTouchesBeforeEnablingAction) // normally actions are enabled before any control actuations happen actionMap.Enable(); @@ -12512,6 +12537,7 @@ public void Actions_WithMultipleCompositeBindings_WithoutEvaluateMagnitude_Works public void Actions_ImprovedShortcutSupport_ConsumesWASD(bool shortcutsEnabled) { InputSystem.settings.shortcutKeysConsumeInput = shortcutsEnabled; + InputSystem.settings.shortcutKeysUseActionPriority = shortcutsEnabled; var keyboard = InputSystem.AddDevice(); @@ -12523,6 +12549,12 @@ public void Actions_ImprovedShortcutSupport_ConsumesWASD(bool shortcutsEnabled) .With("Left", "/a") .With("Right", "/d"); + if (shortcutsEnabled) + { + // Change test to use Priority. + action1.Priority = 1; + } + var map2 = new InputActionMap("map2"); var action2 = map2.AddAction(name: "action2"); action2.AddCompositeBinding("2DVector") @@ -12548,10 +12580,11 @@ public void Actions_ImprovedShortcutSupport_ConsumesWASD(bool shortcutsEnabled) action2.started += ctx => action2Count++; action3.started += ctx => action3Count++; + Press(keyboard.wKey); if (shortcutsEnabled) { - // First action with the most bindings is the ONLY one to trigger + // This is now handled by priority Assert.That(action1Count, Is.EqualTo(1)); Assert.That(action2Count, Is.EqualTo(0)); Assert.That(action3Count, Is.EqualTo(0)); diff --git a/Assets/Tests/InputSystem/CoreTests_ActionsPriority.cs b/Assets/Tests/InputSystem/CoreTests_ActionsPriority.cs new file mode 100644 index 0000000000..9b2e502472 --- /dev/null +++ b/Assets/Tests/InputSystem/CoreTests_ActionsPriority.cs @@ -0,0 +1,971 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.Controls; +using UnityEngine.TestTools; + +internal static class PriorityTestExtensions +{ + internal static InputAction SetupTestAction(this InputActionMap map, string[] bindings) + { + var actionTag = string.Join("+", bindings); + switch (bindings.Length) + { + case 1: + { + var action = map.AddAction($"Action {actionTag}"); + action.AddBinding("/" + bindings[0]); + return action; + } + + case 2: + { + var modifier = bindings[0]; + var binding = bindings[1]; + + var action = map.AddAction($"Action {actionTag}"); + + action.AddCompositeBinding("OneModifier") + .With("Modifier", "/" + modifier) + .With("Binding", "/" + binding); + + return action; + } + + case 3: + { + var modifier1 = bindings[0]; + var modifier2 = bindings[1]; + var binding = bindings[2]; + + var action = map.AddAction($"Action {actionTag}"); + + action.AddCompositeBinding("TwoModifiers") + .With("Modifier1", "/" + modifier1) + .With("Modifier2", "/" + modifier2) + .With("Binding", "/" + binding); + + return action; + } + + default: + return null; + } + } +} + +internal partial class CoreTests +{ + /// + /// Overlap resolution uses and per-control grouping written from actions. + /// + private static void EnableActionPriorityShortcutResolution() + { + InputSystem.settings.shortcutKeysUseActionPriority = true; + InputSystem.settings.shortcutKeysConsumeInput = false; + } + + /// + /// Overlap resolution uses composite binding complexity; is not applied at runtime. + /// Requires shortcut consumption on so control grouping merges slots on the same physical control. + /// + private static void EnableComplexityShortcutResolution() + { + InputSystem.settings.shortcutKeysConsumeInput = true; + InputSystem.settings.shortcutKeysUseActionPriority = false; + } + + private static readonly List<(string[], string[])> k_TwoInputActionTestCases = new() + { + (new[] {"ctrl", "x"}, new[] {"x"}), + (new[] {"shift", "n"}, new[] {"n"}), + (new[] {"ctrl", "shift", "h"}, new[] {"shift", "h"}), + (new[] {"ctrl", "shift", "v"}, new[] {"shift", "v"}), + }; + + [Test] + [Category("Actions Priority")] + public void Actions_Priority_Setter_ClampsToRepresentableRange() + { + var map = new InputActionMap("m"); + var action = map.AddAction("a", binding: "/x"); + + action.Priority = -1; + Assert.That(action.Priority, Is.EqualTo(0)); + + action.Priority = 70000; + Assert.That(action.Priority, Is.EqualTo(65535)); + + action.Priority = 100; + Assert.That(action.Priority, Is.EqualTo(100)); + } + + private void PressBindingsForInputActions(Keyboard keyboard, InputAction action1, InputAction action2, InputAction action3 = null) + { + for (int i = 0; i < action1.controls.Count; i++) + { + Press((ButtonControl)action1.controls[i], queueEventOnly: true); + } + + for (int i = 0; i < action2.controls.Count; i++) + { + Press((ButtonControl)action2.controls[i], queueEventOnly: true); + } + + if (action3 != null) + { + for (int i = 0; i < action3.controls.Count; i++) + { + Press((ButtonControl)action3.controls[i], queueEventOnly: true); + } + } + + InputSystem.Update(); + } + + private void ReleaseBindingsForActions(Keyboard keyboard, InputAction action1, InputAction action2) + { + // Cleanup key presses + for (int i = 0; i < action1.controls.Count; i++) + { + Release((ButtonControl)action1.controls[i], queueEventOnly: true); + } + + for (int i = 0; i < action2.controls.Count; i++) + { + Release((ButtonControl)action2.controls[i], queueEventOnly: true); + } + + InputSystem.Update(); + } + + [Test] + [Category("Actions Priority")] + [TestCaseSource(nameof(k_TwoInputActionTestCases))] + public void Actions_Priority_OnlyOneActionIsFired_WhenOnePriorityIsHigherThanOther((string[] a1, string[] a2) actions) + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + + InputActionMap map = new InputActionMap("map"); + + var action1 = map.SetupTestAction(actions.a1); + var action2 = map.SetupTestAction(actions.a2); + + // action 1's priority higher so it takes precedence + action1.Priority = 2; + action2.Priority = 1; + + action1.m_ActionMap.Enable(); + + Assert.That(action1.WasPerformedThisFrame(), Is.False); + Assert.That(action2.WasPerformedThisFrame(), Is.False); + + PressBindingsForInputActions(keyboard, action1, action2); + + // action1 is performed because action1 has a higher priority than action2. + Assert.That(action1.WasPerformedThisFrame(), Is.True); + Assert.That(action2.WasPerformedThisFrame(), Is.False); + + // Cleanup key presses + ReleaseBindingsForActions(keyboard, action1, action2); + + Assert.That(action1.WasPerformedThisFrame(), Is.False); + Assert.That(action2.WasPerformedThisFrame(), Is.False); + } + + [Test] + [Category("Actions Priority")] + [TestCaseSource(nameof(k_TwoInputActionTestCases))] + public void Actions_Priority_OnlyOneActionIsFired_WhenOnePriorityIsHigherThanOtherInversePriorityOrder((string[] a1, string[] a2) actions) + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + + InputActionMap map = new InputActionMap("map"); + + var action1 = map.SetupTestAction(actions.a1); + var action2 = map.SetupTestAction(actions.a2); + + // action 2's priority higher so it takes precedence + action1.Priority = 1; + action2.Priority = 2; + + action1.m_ActionMap.Enable(); + + Assert.That(action1.WasPerformedThisFrame(), Is.False); + Assert.That(action2.WasPerformedThisFrame(), Is.False); + + PressBindingsForInputActions(keyboard, action1, action2); + + // action2 is performed because action2 has a higher priority than action1. + Assert.That(action1.WasPerformedThisFrame(), Is.False); + Assert.That(action2.WasPerformedThisFrame(), Is.True); + + // Cleanup key presses + ReleaseBindingsForActions(keyboard, action1, action2); + + Assert.That(action1.WasPerformedThisFrame(), Is.False); + Assert.That(action2.WasPerformedThisFrame(), Is.False); + } + + [Test] + [Category("Actions Priority")] + [TestCaseSource(nameof(k_TwoInputActionTestCases))] + public void Actions_Priority_BothActionsArePerformed_DueToKeyPressOrderForShortcut((string[] larger, string[] smaller) actions) + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + + InputActionMap map = new InputActionMap("map"); + + // We swap the order here of Action1 & Action2 so key presses are done backwards, binding before modifiers. + // This causes the opposite keys foreach test case inside TwoInputActionTestCases to be pressed first. + + var smallerBindingAction = map.SetupTestAction(actions.smaller); + var largerBindingAction = map.SetupTestAction(actions.larger); + + // Even though the priority is higher for action2 here, due to the order of the keys being pressed only Action1 will be fired. + smallerBindingAction.Priority = 1; + largerBindingAction.Priority = 2; + + smallerBindingAction.m_ActionMap.Enable(); + + Assert.That(smallerBindingAction.WasPerformedThisFrame(), Is.False); + Assert.That(largerBindingAction.WasPerformedThisFrame(), Is.False); + + PressBindingsForInputActions(keyboard, smallerBindingAction, largerBindingAction); + + // action1 is performed because action1 has a higher priority than action2. + Assert.That(smallerBindingAction.WasPerformedThisFrame(), Is.True); + Assert.That(largerBindingAction.WasPerformedThisFrame(), Is.True); + + // Cleanup key presses + ReleaseBindingsForActions(keyboard, smallerBindingAction, largerBindingAction); + + // Update again to be sure released is true. + InputSystem.Update(); + + Assert.That(smallerBindingAction.WasPerformedThisFrame(), Is.False); + Assert.That(largerBindingAction.WasPerformedThisFrame(), Is.False); + } + + [Test] + [Category("Actions Priority")] + [TestCaseSource(nameof(k_TwoInputActionTestCases))] + public void Actions_Priority_BothActionFires_WhenPriorityIsEqual((string[] a1, string[] a2) actions) + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + + InputActionMap map = new InputActionMap("map"); + + var action1 = map.SetupTestAction(actions.a1); + var action2 = map.SetupTestAction(actions.a2); + + action1.Priority = 5; + action2.Priority = 5; + + action1.m_ActionMap.Enable(); + + PressBindingsForInputActions(keyboard, action1, action2); + + Assert.That(action1.WasPerformedThisFrame(), Is.True); + Assert.That(action2.WasPerformedThisFrame(), Is.True); + } + + [Test] + [Category("Actions Priority")] + [TestCaseSource(nameof(k_TwoInputActionTestCases))] + public void Actions_Priority_BothActionsFire_WhenPriorityIsZero((string[] a1, string[] a2) actions) + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + + InputActionMap map = new InputActionMap("map"); + + var action1 = map.SetupTestAction(actions.a1); + var action2 = map.SetupTestAction(actions.a2); + + action1.Priority = 0; + action2.Priority = 0; + + action1.m_ActionMap.Enable(); + + var action1WasPerformed = false; + var action2WasPerformed = false; + action1.performed += _ => action1WasPerformed = true; + action2.performed += _ => action2WasPerformed = true; + + PressBindingsForInputActions(keyboard, action1, action2); + + Assert.That(action1WasPerformed, Is.True); + Assert.That(action2WasPerformed, Is.True); + } + + private static readonly List<(string[], string[])> k_TwoInputActionNoConflictingBindingTestCases = new() + { + (new[] {"ctrl", "x"}, new[] {"k"}), + (new[] {"shift", "n"}, new[] {"l"}), + (new[] {"shift", "h"}, new[] {"l"}), + (new[] {"shift", "h"}, new[] {"ctrl", "shift", "o"}), + (new[] {"ctrl", "shift", "v"}, new[] {"shift", "z"}) + }; + + [Test] + [Category("Actions Priority")] + [TestCaseSource(nameof(k_TwoInputActionNoConflictingBindingTestCases))] + public void Actions_Priority_BothActionsWithDifferentPriorityFire_WhenThereIsNoConflictingBinding((string[] a1, string[] a2) actions) + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + + InputActionMap map = new InputActionMap("map"); + + var action1 = map.SetupTestAction(actions.a1); + var action2 = map.SetupTestAction(actions.a2); + + action1.Priority = 0; + action2.Priority = 1; + + action1.m_ActionMap.Enable(); + + var action1WasPerformed = false; + action1.performed += _ => action1WasPerformed = true; + + Assert.That(action1.WasPerformedThisFrame(), Is.False); + Assert.That(action2.WasPerformedThisFrame(), Is.False); + + PressBindingsForInputActions(keyboard, action1, action2); + + // Different letter keys: no conflict on the same control, so both shortcuts can perform despite different priorities. + Assert.That(action1WasPerformed, Is.True); + Assert.That(action2.WasPerformedThisFrame(), Is.True); + } + + [Test] + [Category("Actions Priority")] + [TestCaseSource(nameof(k_TwoInputActionNoConflictingBindingTestCases))] + public void Actions_Priority_BothActionsWithDifferentPriorityFire_WhenThereIsNoConflictingBindingInverseOrder((string[] a1, string[] a2) actions) + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + + InputActionMap map = new InputActionMap("map"); + + var action1 = map.SetupTestAction(actions.a1); + var action2 = map.SetupTestAction(actions.a2); + + action1.Priority = 15; + action2.Priority = 5; + + action1.m_ActionMap.Enable(); + + var action1WasPerformed = false; + action1.performed += _ => action1WasPerformed = true; + + Assert.That(action1.WasPerformedThisFrame(), Is.False); + Assert.That(action2.WasPerformedThisFrame(), Is.False); + + PressBindingsForInputActions(keyboard, action1, action2); + + // Different letter keys: no conflict on the same control, so both shortcuts can perform despite different priorities. + Assert.That(action1WasPerformed, Is.True); + Assert.That(action2.WasPerformedThisFrame(), Is.True); + } + + [Test] + [Category("Actions Priority")] + [TestCaseSource(nameof(k_TwoInputActionNoConflictingBindingTestCases))] + public void Actions_Priority_BothActionsWithEqualPriorityFire_WhenThereIsNoConflictingBinding((string[] a1, string[] a2) actions) + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + + InputActionMap map = new InputActionMap("map"); + + var action1 = map.SetupTestAction(actions.a1); + var action2 = map.SetupTestAction(actions.a2); + + action1.Priority = 5; + action2.Priority = 5; + + action1.m_ActionMap.Enable(); + + var action1WasPerformed = false; + action1.performed += _ => action1WasPerformed = true; + + Assert.That(action1.WasPerformedThisFrame(), Is.False); + Assert.That(action2.WasPerformedThisFrame(), Is.False); + + PressBindingsForInputActions(keyboard, action1, action2); + + // Different letter keys: no conflict on the same control, so both shortcuts can perform despite different priorities. + Assert.That(action1WasPerformed, Is.True); + Assert.That(action2.WasPerformedThisFrame(), Is.True); + } + + [Test] + [Category("Actions Priority")] + public void Actions_Priority_ControlGroupingTable_StrideAndElementIndicesMatchInterleavedLayout() + { + Assert.That(InputActionState.ControlGroupingTable.Stride, Is.EqualTo(2)); + Assert.That(InputActionState.ControlGroupingTable.GroupElementIndex(3), Is.EqualTo(6)); + Assert.That(InputActionState.ControlGroupingTable.PriorityElementIndex(3), Is.EqualTo(7)); + } + + [Test] + [Category("Actions Priority")] + public void Actions_Priority_InputActionStateMonitorIndex_RoundTripsComponents() + { + InputActionStateMonitorIndex index = InputActionStateMonitorIndex.Create(mapIndex: 7, controlIndex: 0x00abcdef, bindingIndex: 0x0bcd, + priority: 200); + + Assert.That(index.MapIndex, Is.EqualTo(7)); + Assert.That(index.ControlIndex, Is.EqualTo(0x00abcdef)); + Assert.That(index.BindingIndex, Is.EqualTo(0x0bcd)); + Assert.That(index.Priority, Is.EqualTo(200)); + } + + [Test] + [Category("Actions Priority")] + public void Actions_Priority_InputActionStateMonitorIndex_FromPacked_MatchesCreateOutput() + { + var created = InputActionStateMonitorIndex.Create(3, 100, 200, 42); + var roundTrip = InputActionStateMonitorIndex.FromPacked(created.Packed); + + Assert.That(roundTrip.MapIndex, Is.EqualTo(created.MapIndex)); + Assert.That(roundTrip.ControlIndex, Is.EqualTo(created.ControlIndex)); + Assert.That(roundTrip.BindingIndex, Is.EqualTo(created.BindingIndex)); + Assert.That(roundTrip.Priority, Is.EqualTo(created.Priority)); + } + + [Test] + [Category("Actions Priority")] + // Priority is stored as a 16-bit field in the packed index. This test ensures values above byte.MaxValue (255) + // are not silently truncated, confirming the field is ushort-wide end-to-end. + public void Actions_Priority_InputActionStateMonitorIndex_PriorityRoundTripsFullSixteenBits() + { + var index300 = InputActionStateMonitorIndex.Create(0, 1, 0, priority: 300); + Assert.That(index300.Priority, Is.EqualTo(300)); + Assert.That(InputActionState.GetComplexityFromMonitorIndex(index300.Packed), Is.EqualTo(300)); + + var index65535 = InputActionStateMonitorIndex.Create(0, 1, 0, priority: 65535); + Assert.That(index65535.Priority, Is.EqualTo(65535)); + } + + [Test] + [Category("Actions Priority")] + // Priority is a ushort (0–65535). This verifies that values above byte.MaxValue (255) still resolve in + // the correct order, guarding against accidental byte-truncation in the sort path. + public void Actions_Priority_PrioritiesExceedingByteRange_ResolveInOrder() + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + var map = new InputActionMap("map"); + var lower = map.AddAction("lower", binding: "/x"); + var higher = map.AddAction("higher", binding: "/x"); + lower.Priority = 300; + higher.Priority = 400; + map.Enable(); + + Press((ButtonControl)keyboard.xKey, queueEventOnly: true); + InputSystem.Update(); + + Assert.That(higher.WasPerformedThisFrame(), Is.True); + Assert.That(lower.WasPerformedThisFrame(), Is.False); + + Release((ButtonControl)keyboard.xKey); + InputSystem.Update(); + } + + [Test] + [Category("Actions Priority")] + public void Actions_Priority_ChangingPriorityWhileEnabled_ReplacesStateMonitorInsteadOfDuplicating() + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + var map = new InputActionMap("map"); + var action = map.AddAction("a", binding: "/x"); + action.Priority = 1; + map.Enable(); + + var state = map.m_State; + Assert.That(state, Is.Not.Null); + + var control = keyboard.xKey; + var deviceIndex = keyboard.m_DeviceIndex; + Assert.That(deviceIndex, Is.GreaterThanOrEqualTo(0)); + + int CountMonitorsForActionStateOnControl() + { + ref var bucket = ref InputSystem.manager.m_StateMonitors.m_MonitorsPerDevice[deviceIndex]; + var c = 0; + for (var i = 0; i < bucket.count; ++i) + { + if (bucket.memoryRegions[i].sizeInBits == 0) + continue; + if (ReferenceEquals(bucket.listeners[i].monitor, state) && bucket.listeners[i].control == control) + ++c; + } + + return c; + } + + Assert.That(CountMonitorsForActionStateOnControl(), Is.EqualTo(1)); + + action.Priority = 5; + action.Priority = 10; + action.Priority = 20; + + Assert.That(CountMonitorsForActionStateOnControl(), Is.EqualTo(1)); + + var performedCount = 0; + action.performed += _ => performedCount++; + Press((ButtonControl)keyboard.xKey, queueEventOnly: true); + InputSystem.Update(); + Assert.That(performedCount, Is.EqualTo(1)); + + Release((ButtonControl)keyboard.xKey); + InputSystem.Update(); + } + + [Test] + [Category("Actions Priority")] + public void Actions_Priority_ChangingPriorityOnCompositeAction_UpdatesMonitorPackedPriorityOnPartControls() + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + var map = new InputActionMap("map"); + var shiftB = map.AddAction("shiftB"); + shiftB.AddCompositeBinding("OneModifier") + .With("Modifier", "/leftShift") + .With("Binding", "/b"); + shiftB.Priority = 3; + map.Enable(); + + var state = map.m_State; + Assert.That(state, Is.Not.Null); + + static int PackedPriorityForMonitor(InputActionState actionState, InputControl control) + { + var deviceIndex = control.device.m_DeviceIndex; + ref var bucket = ref InputSystem.manager.m_StateMonitors.m_MonitorsPerDevice[deviceIndex]; + for (var i = 0; i < bucket.count; ++i) + { + if (bucket.memoryRegions[i].sizeInBits == 0) + continue; + if (!ReferenceEquals(bucket.listeners[i].monitor, actionState) || + bucket.listeners[i].control != control) + continue; + return InputActionStateMonitorIndex.FromPacked(bucket.listeners[i].monitorIndex).Priority; + } + + return int.MinValue; + } + + Assert.That(PackedPriorityForMonitor(state, keyboard.bKey), Is.EqualTo(3)); + Assert.That(PackedPriorityForMonitor(state, keyboard.leftShiftKey), Is.EqualTo(3)); + + shiftB.Priority = 7; + + Assert.That(PackedPriorityForMonitor(state, keyboard.bKey), Is.EqualTo(7)); + Assert.That(PackedPriorityForMonitor(state, keyboard.leftShiftKey), Is.EqualTo(7)); + } + + [Test] + [Category("Actions Priority")] + public void Actions_Priority_InputActionStateMonitorIndex_ImplicitConversionToLongMatchesPackedProperty() + { + InputActionStateMonitorIndex index = InputActionStateMonitorIndex.Create(1, 2, 3, 4); + long asLong = index; + Assert.That(asLong, Is.EqualTo(index.Packed)); + } + + [Test] + [Category("Actions Priority")] + public unsafe void Actions_Priority_ControlGrouping_SamePhysicalControlSharesGroupId() + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + var map = new InputActionMap("priority_group_test"); + map.AddAction("a", binding: "/z"); + map.AddAction("b", binding: "/z"); + map.Enable(); + + var state = map.m_State; + Assert.That(state, Is.Not.Null); + Assert.That(state.memory.controlGroupingInitialized, Is.True); + + for (var i = 0; i < state.totalControlCount; ++i) + { + for (var j = i + 1; j < state.totalControlCount; ++j) + { + if (state.controls[i] != state.controls[j]) + continue; + + var gi = InputActionState.ControlGroupingTable.GroupElementIndex(i); + var gj = InputActionState.ControlGroupingTable.GroupElementIndex(j); + Assert.That(state.memory.controlGroupingAndPriority[gi], Is.EqualTo(state.memory.controlGroupingAndPriority[gj])); + Assert.That(state.memory.controlGroupingAndPriority[gi], Is.Not.EqualTo(0)); + return; + } + } + + Assert.Fail("Expected two control slots bound to the same physical control."); + } + + [Test] + [Category("Actions Priority")] + public unsafe void Actions_Priority_ControlGrouping_WritesPerControlSlotPriorityFromAction() + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + var map = new InputActionMap("priority_per_slot_test"); + var actionLow = map.AddAction("low", binding: "/x"); + var actionHigh = map.AddAction("high", binding: "/x"); + actionLow.Priority = 4; + actionHigh.Priority = 11; + map.Enable(); + + var state = map.m_State; + Assert.That(state, Is.Not.Null); + + var lowIndex = -1; + var highIndex = -1; + for (var i = 0; i < state.totalControlCount; ++i) + { + if (state.controls[i] != keyboard.xKey) + continue; + var bindingIndex = state.controlIndexToBindingIndex[i]; + var actionIndex = state.bindingStates[bindingIndex].actionIndex; + if (actionIndex == actionLow.m_ActionIndexInState) + lowIndex = i; + else if (actionIndex == actionHigh.m_ActionIndexInState) + highIndex = i; + } + + Assert.That(lowIndex, Is.GreaterThanOrEqualTo(0)); + Assert.That(highIndex, Is.GreaterThanOrEqualTo(0)); + + var pLow = InputActionState.ControlGroupingTable.PriorityElementIndex(lowIndex); + var pHigh = InputActionState.ControlGroupingTable.PriorityElementIndex(highIndex); + Assert.That(state.memory.controlGroupingAndPriority[pLow], Is.EqualTo(4)); + Assert.That(state.memory.controlGroupingAndPriority[pHigh], Is.EqualTo(11)); + } + + /// + /// Shift+B with hold(duration=2) reaches after two seconds of + /// continuous hold (real time on the test runtime clock). + /// + [UnityTest] + [Category("Actions Priority")] + public IEnumerator Actions_Priority_BothActionsArePerformed_WhenAHoldAndBasicActionHaveDifferentTiming() + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + using var map = new InputActionMap("HoldChord"); + + var plainB = map.AddAction("PlainB", InputActionType.Button, "/b"); + var shiftBHold = map.AddAction("ShiftBHold", InputActionType.Button, binding: null, interactions: "hold(duration=2)"); + shiftBHold.AddCompositeBinding("OneModifier(modifiersOrder=2)") + .With("modifier", "/shift") + .With("binding", "/b"); + plainB.Priority = 0; + shiftBHold.Priority = 1; + + var plainBPerformed = false; + plainB.performed += _ => plainBPerformed = true; + + map.Enable(); + + var t0 = currentTime; + Press(keyboard.leftShiftKey); + Press(keyboard.bKey); + InputSystem.Update(); + yield return null; + + Assert.AreNotEqual( + InputActionPhase.Performed, + shiftBHold.phase, + "Hold should not be Performed until the hold duration elapses."); + + currentTime = t0 + 2.1; + InputSystem.Update(); + yield return null; + + Assert.IsTrue(plainBPerformed); + Assert.IsTrue( + shiftBHold.phase == InputActionPhase.Performed, + "Hold should complete to Performed after the hold duration with keys still down."); + + Release(keyboard.bKey); + Release(keyboard.leftShiftKey); + map.Disable(); + } + + /// + /// Shift+B with hold(duration=2) reaches after two seconds of + /// continuous hold (real time on the test runtime clock). + /// + [UnityTest] + [Category("Actions Priority")] + public IEnumerator Actions_Priority_OnlyOneHoldActionIsPerformed_WhenOnePriorityIsHigher() + { + EnableActionPriorityShortcutResolution(); + var keyboard = InputSystem.AddDevice(); + using var map = new InputActionMap("HoldChord"); + + var plainB = map.AddAction("PlainB", InputActionType.Button, "/b", interactions: "hold(duration=2)"); + var shiftBHold = map.AddAction("ShiftBHold", InputActionType.Button, binding: null, interactions: "hold(duration=2)"); + shiftBHold.AddCompositeBinding("OneModifier(modifiersOrder=2)") + .With("modifier", "/shift") + .With("binding", "/b"); + plainB.Priority = 0; + shiftBHold.Priority = 1; + + var plainBPerformed = false; + plainB.performed += _ => plainBPerformed = true; + + map.Enable(); + + var t0 = currentTime; + Press(keyboard.leftShiftKey); + Press(keyboard.bKey); + InputSystem.Update(); + yield return null; + + Assert.AreNotEqual( + InputActionPhase.Performed, + shiftBHold.phase, + "Hold should not be Performed until the hold duration elapses."); + + currentTime = t0 + 2.1; + InputSystem.Update(); + yield return null; + + Assert.IsTrue(plainBPerformed); + Assert.IsTrue( + shiftBHold.phase == InputActionPhase.Performed, + "Hold should complete to Performed after the hold duration with keys still down."); + + Release(keyboard.bKey); + Release(keyboard.leftShiftKey); + map.Disable(); + } + + [Test] + [Category("Actions Priority")] + public unsafe void Actions_Complexity_ControlGrouping_SamePhysicalControlSharesGroupId_WhenShortcutConsumptionEnabled() + { + EnableComplexityShortcutResolution(); + + InputSystem.AddDevice(); + var map = new InputActionMap("complexity_group_test"); + map.AddAction("a", binding: "/z"); + map.AddAction("b", binding: "/z"); + map.Enable(); + + var state = map.m_State; + Assert.That(state, Is.Not.Null); + Assert.That(state.memory.controlGroupingInitialized, Is.True); + + for (var i = 0; i < state.totalControlCount; ++i) + { + for (var j = i + 1; j < state.totalControlCount; ++j) + { + if (state.controls[i] != state.controls[j]) + continue; + + var gi = InputActionState.ControlGroupingTable.GroupElementIndex(i); + var gj = InputActionState.ControlGroupingTable.GroupElementIndex(j); + Assert.That(state.memory.controlGroupingAndPriority[gi], Is.EqualTo(state.memory.controlGroupingAndPriority[gj])); + Assert.That(state.memory.controlGroupingAndPriority[gi], Is.Not.EqualTo(0)); + return; + } + } + + Assert.Fail("Expected two control slots bound to the same physical control."); + } + + [Test] + [Category("Actions Priority")] + // In complexity mode the secondary column of the control-grouping table holds binding-chain depth (composite + // complexity), not the action's Priority value. Two simple (non-composite) bindings on the same key each have + // depth 1 regardless of what Priority is set on their actions, because Priority is irrelevant in this mode. + public unsafe void Actions_Complexity_ControlGrouping_WritesPerControlSlotComplexity_NotActionPriority() + { + EnableComplexityShortcutResolution(); + + var keyboard = InputSystem.AddDevice(); + var map = new InputActionMap("complexity_per_slot_test"); + var actionLow = map.AddAction("low", binding: "/x"); + var actionHigh = map.AddAction("high", binding: "/x"); + actionLow.Priority = 4; + actionHigh.Priority = 11; + map.Enable(); + + var state = map.m_State; + Assert.That(state, Is.Not.Null); + + var lowIndex = -1; + var highIndex = -1; + for (var i = 0; i < state.totalControlCount; ++i) + { + if (state.controls[i] != keyboard.xKey) + continue; + var bindingIndex = state.controlIndexToBindingIndex[i]; + var actionIndex = state.bindingStates[bindingIndex].actionIndex; + if (actionIndex == actionLow.m_ActionIndexInState) + lowIndex = i; + else if (actionIndex == actionHigh.m_ActionIndexInState) + highIndex = i; + } + + Assert.That(lowIndex, Is.GreaterThanOrEqualTo(0)); + Assert.That(highIndex, Is.GreaterThanOrEqualTo(0)); + + var pLow = InputActionState.ControlGroupingTable.PriorityElementIndex(lowIndex); + var pHigh = InputActionState.ControlGroupingTable.PriorityElementIndex(highIndex); + // Secondary column stores composite complexity; two simple bindings on the same key both have depth 1. + Assert.That(state.memory.controlGroupingAndPriority[pLow], Is.EqualTo(1)); + Assert.That(state.memory.controlGroupingAndPriority[pHigh], Is.EqualTo(1)); + } + + [Test] + [Category("Actions Priority")] + public unsafe void Actions_Complexity_ControlGrouping_WritesHigherComplexityOnSharedControlVersusSimpleBinding() + { + EnableComplexityShortcutResolution(); + + var keyboard = InputSystem.AddDevice(); + var map = new InputActionMap("complexity_composite_vs_simple"); + var composite = map.AddAction("chord", binding: null); + composite.AddCompositeBinding("OneModifier") + .With("Modifier", "/ctrl") + .With("Binding", "/x"); + var simple = map.AddAction("plain", binding: "/x"); + composite.Priority = 0; + simple.Priority = 99; + map.Enable(); + + var state = map.m_State; + Assert.That(state, Is.Not.Null); + + var compositeXIndex = -1; + var simpleXIndex = -1; + for (var i = 0; i < state.totalControlCount; ++i) + { + if (state.controls[i] != keyboard.xKey) + continue; + var bindingIndex = state.controlIndexToBindingIndex[i]; + var actionIndex = state.bindingStates[bindingIndex].actionIndex; + if (actionIndex == composite.m_ActionIndexInState) + compositeXIndex = i; + else if (actionIndex == simple.m_ActionIndexInState) + simpleXIndex = i; + } + + Assert.That(compositeXIndex, Is.GreaterThanOrEqualTo(0)); + Assert.That(simpleXIndex, Is.GreaterThanOrEqualTo(0)); + + var pComposite = InputActionState.ControlGroupingTable.PriorityElementIndex(compositeXIndex); + var pSimple = InputActionState.ControlGroupingTable.PriorityElementIndex(simpleXIndex); + Assert.That(state.memory.controlGroupingAndPriority[pSimple], Is.EqualTo(1)); + Assert.That( + state.memory.controlGroupingAndPriority[pComposite], + Is.GreaterThan(state.memory.controlGroupingAndPriority[pSimple]), + "Composite binding chain depth should exceed a simple binding on the same physical control."); + } + + [Test] + [Category("Actions Priority")] + [TestCaseSource(nameof(k_TwoInputActionTestCases))] + public void Actions_Complexity_CompositeWinsOverlappingSimple_IgnoresActionPriority((string[] a1, string[] a2) actions) + { + EnableComplexityShortcutResolution(); + + var keyboard = InputSystem.AddDevice(); + var map = new InputActionMap("map"); + + var actionComposite = map.SetupTestAction(actions.a1); + var actionSimple = map.SetupTestAction(actions.a2); + + // Deliberately favor the simple binding in the Priority field; complexity resolution must still prefer the composite. + actionComposite.Priority = 0; + actionSimple.Priority = 100; + + map.Enable(); + + Assert.That(actionComposite.WasPerformedThisFrame(), Is.False); + Assert.That(actionSimple.WasPerformedThisFrame(), Is.False); + + PressBindingsForInputActions(keyboard, actionComposite, actionSimple); + + Assert.That(actionComposite.WasPerformedThisFrame(), Is.True); + Assert.That(actionSimple.WasPerformedThisFrame(), Is.False); + + ReleaseBindingsForActions(keyboard, actionComposite, actionSimple); + + InputSystem.Update(); + + Assert.That(actionComposite.WasPerformedThisFrame(), Is.False); + Assert.That(actionSimple.WasPerformedThisFrame(), Is.False); + } + + [Test] + [Category("Actions Priority")] + [TestCaseSource(nameof(k_TwoInputActionTestCases))] + public void Actions_Complexity_CompositeWinsOverlappingSimple_EvenWhenCompositeHasHigherPriorityField( + (string[] a1, string[] a2) actions) + { + EnableComplexityShortcutResolution(); + + var keyboard = InputSystem.AddDevice(); + var map = new InputActionMap("map"); + + var actionComposite = map.SetupTestAction(actions.a1); + var actionSimple = map.SetupTestAction(actions.a2); + + actionComposite.Priority = 100; + actionSimple.Priority = 1; + + map.Enable(); + + PressBindingsForInputActions(keyboard, actionComposite, actionSimple); + + Assert.That(actionComposite.WasPerformedThisFrame(), Is.True); + Assert.That(actionSimple.WasPerformedThisFrame(), Is.False); + + ReleaseBindingsForActions(keyboard, actionComposite, actionSimple); + } + + [Test] + [Category("Actions Priority")] + public void Actions_Complexity_BothSimpleActionsOnSameControlPerform_WhenEqualComplexity() + { + EnableComplexityShortcutResolution(); + + var keyboard = InputSystem.AddDevice(); + var map = new InputActionMap("map"); + var action1 = map.AddAction("a", binding: "/y"); + var action2 = map.AddAction("b", binding: "/y"); + action1.Priority = 2; + action2.Priority = 99; + map.Enable(); + + Press((ButtonControl)action1.controls[0], queueEventOnly: true); + Press((ButtonControl)action2.controls[0], queueEventOnly: true); + InputSystem.Update(); + + Assert.That(action1.WasPerformedThisFrame(), Is.True); + Assert.That(action2.WasPerformedThisFrame(), Is.True); + + Release((ButtonControl)action1.controls[0], queueEventOnly: true); + Release((ButtonControl)action2.controls[0], queueEventOnly: true); + InputSystem.Update(); + } +} diff --git a/Assets/Tests/InputSystem/CoreTests_ActionsPriority.cs.meta b/Assets/Tests/InputSystem/CoreTests_ActionsPriority.cs.meta new file mode 100644 index 0000000000..fa702aebcb --- /dev/null +++ b/Assets/Tests/InputSystem/CoreTests_ActionsPriority.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: d82c47e3da544616936b989711abed4b +timeCreated: 1775041096 \ No newline at end of file diff --git a/Assets/Tests/InputSystem/CoreTests_Analytics.cs b/Assets/Tests/InputSystem/CoreTests_Analytics.cs index 7b75e0669d..2a0025b53d 100644 --- a/Assets/Tests/InputSystem/CoreTests_Analytics.cs +++ b/Assets/Tests/InputSystem/CoreTests_Analytics.cs @@ -451,6 +451,7 @@ public void Analytics_ShouldReportBuildAnalytics_WhenNotHavingSettingsAsset() Assert.That(data.supported_devices, Is.EqualTo(defaultSettings.supportedDevices)); Assert.That(data.disable_redundant_events_merging, Is.EqualTo(defaultSettings.disableRedundantEventsMerging)); Assert.That(data.shortcut_keys_consume_input, Is.EqualTo(defaultSettings.shortcutKeysConsumeInput)); + Assert.That(data.shortcut_keys_use_action_priority, Is.EqualTo(defaultSettings.shortcutKeysUseActionPriority)); Assert.That(data.feature_optimized_controls_enabled, Is.EqualTo(defaultSettings.IsFeatureEnabled(InputFeatureNames.kUseOptimizedControls))); Assert.That(data.feature_read_value_caching_enabled, Is.EqualTo(defaultSettings.IsFeatureEnabled(InputFeatureNames.kUseReadValueCaching))); @@ -499,6 +500,7 @@ public void Analytics_ShouldReportBuildAnalytics_WhenHavingSettingsAssetWithCust customSettings.supportedDevices = Array.Empty(); customSettings.disableRedundantEventsMerging = true; customSettings.shortcutKeysConsumeInput = true; + customSettings.shortcutKeysUseActionPriority = true; customSettings.SetInternalFeatureFlag(InputFeatureNames.kUseOptimizedControls, true); customSettings.SetInternalFeatureFlag(InputFeatureNames.kParanoidReadValueCachingChecks, true); @@ -545,6 +547,7 @@ public void Analytics_ShouldReportBuildAnalytics_WhenHavingSettingsAssetWithCust Assert.That(data.supported_devices, Is.EqualTo(customSettings.supportedDevices)); Assert.That(data.disable_redundant_events_merging, Is.EqualTo(customSettings.disableRedundantEventsMerging)); Assert.That(data.shortcut_keys_consume_input, Is.EqualTo(customSettings.shortcutKeysConsumeInput)); + Assert.That(data.shortcut_keys_use_action_priority, Is.EqualTo(customSettings.shortcutKeysUseActionPriority)); Assert.That(data.feature_optimized_controls_enabled, Is.True); Assert.That(data.feature_read_value_caching_enabled, Is.True); diff --git a/Assets/Tests/InputSystem/CoreTests_Editor.cs b/Assets/Tests/InputSystem/CoreTests_Editor.cs index 5c905c8c23..48b7a5f680 100644 --- a/Assets/Tests/InputSystem/CoreTests_Editor.cs +++ b/Assets/Tests/InputSystem/CoreTests_Editor.cs @@ -2965,8 +2965,8 @@ public void Editor_LeavingPlayMode_DestroysAllActionStates() action.Enable(); Assert.That(InputActionState.s_GlobalState.globalList.length, Is.EqualTo(1)); - Assert.That(InputSystem.manager.m_StateChangeMonitors.Length, Is.GreaterThan(0)); - Assert.That(InputSystem.manager.m_StateChangeMonitors[0].count, Is.EqualTo(1)); + Assert.That(InputSystem.manager.m_StateMonitors.m_MonitorsPerDevice.Length, Is.GreaterThan(0)); + Assert.That(InputSystem.manager.m_StateMonitors.m_MonitorsPerDevice[0].count, Is.EqualTo(1)); // Exit play mode. InputSystemEditorInitializer.OnPlayModeChange(PlayModeStateChange.ExitingPlayMode); @@ -2974,7 +2974,7 @@ public void Editor_LeavingPlayMode_DestroysAllActionStates() Assert.That(InputActionState.s_GlobalState.globalList.length, Is.Zero); // Won't get removed, just cleared. - Assert.That(InputSystem.manager.m_StateChangeMonitors[0].listeners[0].control, Is.Null); + Assert.That(InputSystem.manager.m_StateMonitors.m_MonitorsPerDevice[0].listeners[0].control, Is.Null); } [Test] diff --git a/Assets/Tests/InputSystem/CoreTests_State.cs b/Assets/Tests/InputSystem/CoreTests_State.cs index ed5d59d8aa..0858be6fad 100644 --- a/Assets/Tests/InputSystem/CoreTests_State.cs +++ b/Assets/Tests/InputSystem/CoreTests_State.cs @@ -1887,4 +1887,23 @@ public void TODO_State_WithSingleStateAndSingleUpdate_XXXXX() ////TODO Assert.Fail(); } + + [Test] + [Category("State")] + public void State_InputManagerInstallRuntime_WithSameRuntimeInstance_DoesNotReplaceStateMonitors() + { + InputSystem.AddDevice(); + using (var map = new InputActionMap("install_runtime_monitors")) + { + map.AddAction("a", binding: "/space"); + map.Enable(); + + var monitors = InputSystem.manager.m_StateMonitors; + Assert.That(monitors, Is.Not.Null); + + InputSystem.manager.InstallRuntime(InputSystem.manager.runtime); + + Assert.That(ReferenceEquals(InputSystem.manager.m_StateMonitors, monitors), Is.True); + } + } } diff --git a/Assets/Tests/InputSystem/InputActionCodeGeneratorActions.cs b/Assets/Tests/InputSystem/InputActionCodeGeneratorActions.cs index e70032896d..9167dc741f 100644 --- a/Assets/Tests/InputSystem/InputActionCodeGeneratorActions.cs +++ b/Assets/Tests/InputSystem/InputActionCodeGeneratorActions.cs @@ -99,7 +99,8 @@ public @InputActionCodeGeneratorActions() ""expectedControlType"": ""Button"", ""processors"": """", ""interactions"": """", - ""initialStateCheck"": false + ""initialStateCheck"": false, + ""priority"": 0 }, { ""name"": ""action2"", @@ -108,7 +109,8 @@ public @InputActionCodeGeneratorActions() ""expectedControlType"": ""Button"", ""processors"": """", ""interactions"": """", - ""initialStateCheck"": false + ""initialStateCheck"": false, + ""priority"": 0 } ], ""bindings"": [ diff --git a/Packages/com.unity.inputsystem/CHANGELOG.md b/Packages/com.unity.inputsystem/CHANGELOG.md index f64be23383..4ad853594d 100644 --- a/Packages/com.unity.inputsystem/CHANGELOG.md +++ b/Packages/com.unity.inputsystem/CHANGELOG.md @@ -39,6 +39,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- Added `InputSettings.shortcutKeysUseActionPriority` to opt into action-priority based shortcut overlap resolution. `shortcutKeysConsumeInput` enables complexity-based resolution when action priority is off (matching previous develop behavior). When both are enabled, action priority takes precedence. Serialized `InputAction` priority values are always kept; the Priority field in the Input Actions editor is shown only when action priority resolution is enabled. Both settings default to off. + - Support for entering the play mode with domain reload turned off (i.e. Faster Enter Playmode feature) [ISX-2411] ## [1.19.0] - 2026-02-24 diff --git a/Packages/com.unity.inputsystem/Documentation~/ActionBindings.md b/Packages/com.unity.inputsystem/Documentation~/ActionBindings.md index b98c67084d..134b5839ac 100644 --- a/Packages/com.unity.inputsystem/Documentation~/ActionBindings.md +++ b/Packages/com.unity.inputsystem/Documentation~/ActionBindings.md @@ -892,6 +892,9 @@ By using the [Pass-Through](xref:input-system-responding#pass-through) action ty > [!NOTE] > The mechanism described here only applies to actions that are part of the same [`InputActionMap`](xref:UnityEngine.InputSystem.InputActionMap) or [`InputActionAsset`](xref:UnityEngine.InputSystem.InputActionAsset). +> [!NOTE] +> To use automatic composite complexity as described below, in **Project Settings** > **Input System Package**, enable __Complexity-Based Shortcut Resolution__ ([`InputSettings.shortcutKeysConsumeInput`](xref:UnityEngine.InputSystem.InputSettings.shortcutKeysConsumeInput)) and leave __Action Priority Shortcut Resolution__ ([`InputSettings.shortcutKeysUseActionPriority`](xref:UnityEngine.InputSystem.InputSettings.shortcutKeysUseActionPriority)) disabled. If __Action Priority Shortcut Resolution__ is enabled, overlaps are resolved using each action's [`InputAction.Priority`](xref:UnityEngine.InputSystem.InputAction.Priority) instead. Refer to [Input settings](xref:input-system-settings#improved-shortcut-support) for details. + Inputs that are used in combinations with other inputs may also lead to ambiguities. If, for example, the `b` key on the Keyboard is bound both on its own as well as in combination with the `shift` key, then if you first press `shift` and then `b`, the latter key press would be a valid input for either of the actions. The way this is handled is that bindings will be processed in the order of decreasing "complexity". This metric is derived automatically from the binding: diff --git a/Packages/com.unity.inputsystem/Documentation~/Actions.md b/Packages/com.unity.inputsystem/Documentation~/Actions.md index 778266df3a..50b0d35821 100644 --- a/Packages/com.unity.inputsystem/Documentation~/Actions.md +++ b/Packages/com.unity.inputsystem/Documentation~/Actions.md @@ -152,3 +152,29 @@ When you enable an action, the Input System resolves its bindings, unless it has You can't change certain aspects of the configuration, such as action bindings, while an action is enabled. To stop actions or action maps from responding to input, call [`Disable`](xref:UnityEngine.InputSystem.InputAction.Disable). While enabled, an action actively monitors the [controls](xref:input-system-controls) it's bound to. If a bound control changes state, the action processes the change. If the control's change represents an [interaction](xref:input-system-interactions) change, the action creates a response. All of this happens during the Input System update logic. Depending on the [update mode](xref:input-system-settings#update-mode) selected in the input settings, this happens once every frame, once every fixed update, or manually if updates are set to manual. + +## Overlapping bindings and action priority + +When several enabled actions share the same physical control (for example a plain **B** key action and a **Shift**+**B** composite), the Input System can resolve which action should respond first using either complexity-based or priority-based resolution. Both modes are configured in **Project Settings** > **Input System Package** under [Improved Shortcut Support](xref:input-system-settings#improved-shortcut-support). + +Each action has a [`Priority`](xref:UnityEngine.InputSystem.InputAction.Priority) property. The range is from 0 to 65535, and is clamped when set. A higher value means a higher priority, notified first. + +The `Priority` value applies to all bindings on that action. Serialized priority is always stored on the asset; at runtime it's used only when **Action Priority Shortcut Resolution** is enabled. In that case, the **Priority** field is also shown in the [Input Actions Editor](xref:input-system-configuring-input). + +When action priority resolution is active: + +- Higher priority actions are notified before lower-priority actions on the same control. +- When an action reaches the Performed phase, priority `0 doesn't mark the input event as handled, so lower-priority actions in the same overlap group can still respond on that event. +- Any priority **greater than zero** can mark the event handled and suppress strictly lower-priority actions in the same group for that event. +- Actions with the **same** priority are not suppressed relative to each other; both can perform in the same update if their bindings fire. + +Set priority in code: + +```CSharp +fireAction.Priority = 10; +reloadAction.Priority = 5; +``` + +You can also edit the **Priority** field on an action in the Input Actions Editor when **Action Priority Shortcut Resolution** is enabled. + +For information about composite shortcuts and complexity ordering, refer to [Multiple input sequences (such as keyboard shortcuts)](xref:input-system-action-bindings#multiple-input-sequences-such-as-keyboard-shortcuts). diff --git a/Packages/com.unity.inputsystem/Documentation~/Settings.md b/Packages/com.unity.inputsystem/Documentation~/Settings.md index d75d9fde7e..713fb4ba99 100644 --- a/Packages/com.unity.inputsystem/Documentation~/Settings.md +++ b/Packages/com.unity.inputsystem/Documentation~/Settings.md @@ -120,6 +120,19 @@ To force the Editor to add all locally available Devices, even if they're not in > [!NOTE] > This setting is stored as a user setting, not a project setting. This means other users who open the project in their own Editor do not share the setting. +## Improved Shortcut Support + +Under **Improved Shortcut Support**, two independent options control how the Input System resolves overlapping bindings on the same control (for example, a plain **B** key action as opposed to **Shift**+**B**): + +| UI / API | Description | +| -------- | ----------- | +| __Complexity-Based Shortcut Resolution__ ([`shortcutKeysConsumeInput`](xref:UnityEngine.InputSystem.InputSettings.shortcutKeysConsumeInput)) | When enabled and Action Priority Shortcut Resolution is off, composite bindings are ordered by automatic **complexity** (depth of the composite). The first action that performs can consume the input so simpler bindings on the same control are skipped. Modifier composites use **ordered** modifier evaluation while this mode is active. | +| __Action Priority Shortcut Resolution__ ([`shortcutKeysUseActionPriority`](xref:UnityEngine.InputSystem.InputSettings.shortcutKeysUseActionPriority)) | When enabled, resolution uses each action's [`InputAction.Priority`](xref:UnityEngine.InputSystem.InputAction.Priority). Higher priority is handled first and can consume input for lower-priority actions. A performed action with priority 0 does not mark the input event as handled for suppressing lower-priority overlaps; any priority above zero can. The **Priority** field appears in the Input Actions editor when this is on. Serialized priority values are always kept on the asset even when this option is off. | + +Both options default to **off**. If **Action Priority Shortcut Resolution** is on, it **takes precedence** over complexity-based resolution even if **Complexity-Based Shortcut Resolution** is also enabled. + +For more detail on complexity ordering, see [Multiple input sequences (such as keyboard shortcuts)](xref:input-system-action-bindings#multiple-input-sequences-such-as-keyboard-shortcuts). + ## Platform-specific settings ### iOS/tvOS diff --git a/Packages/com.unity.inputsystem/InputSystem/Actions.meta b/Packages/com.unity.inputsystem/InputSystem/Actions.meta new file mode 100644 index 0000000000..330a604fb5 --- /dev/null +++ b/Packages/com.unity.inputsystem/InputSystem/Actions.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0c5b615422acd45049de003ce7134437 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.unity.inputsystem/InputSystem/Actions/InputActionStateMonitorIndex.cs b/Packages/com.unity.inputsystem/InputSystem/Actions/InputActionStateMonitorIndex.cs new file mode 100644 index 0000000000..dfa768d8d7 --- /dev/null +++ b/Packages/com.unity.inputsystem/InputSystem/Actions/InputActionStateMonitorIndex.cs @@ -0,0 +1,50 @@ +namespace UnityEngine.InputSystem +{ + /// + /// Bit-packed id registered with for state change monitors. + /// Layout must match TriggerState.kMaxNum* limits on . + /// + internal readonly struct InputActionStateMonitorIndex + { + // Bit layout (64 bits total): + // [0–23] controlIndex (24 bits) + // [24–39] bindingIndex (16 bits) + // [40–47] mapIndex (8 bits) + // [48–63] priority or composite complexity (ushort, 16 bits) + readonly long m_Packed; + + private InputActionStateMonitorIndex(long packed) + { + m_Packed = packed; + } + + public long Packed => m_Packed; + + public static implicit operator long(InputActionStateMonitorIndex index) => index.m_Packed; + + public static InputActionStateMonitorIndex FromPacked(long packed) => new InputActionStateMonitorIndex(packed); + + public static InputActionStateMonitorIndex Create(int mapIndex, int controlIndex, int bindingIndex, int priority) + { + long result = controlIndex; + result |= (long)bindingIndex << 24; + result |= (long)mapIndex << 40; + // Bits 48–63 hold priority or composite complexity (ushort); use ulong shift so values ≥32768 + // pack without corrupting the signed long layout. + var priorityBits = (ulong)(ushort)(priority & 0xffff); + result |= (long)(priorityBits << 48); + return new InputActionStateMonitorIndex(result); + } + + public int ControlIndex => (int)(m_Packed & 0x00ffffff); + + public int BindingIndex => (int)((m_Packed >> 24) & 0xffff); + + public int MapIndex => (int)((m_Packed >> 40) & 0xff); + + /// + /// The high 16 bits of the packed index (matching the ushort priority/complexity slot in ). + /// + public int Priority => (int)(((ulong)m_Packed >> 48) & 0xffff); + } +} diff --git a/Packages/com.unity.inputsystem/InputSystem/Actions/InputActionStateMonitorIndex.cs.meta b/Packages/com.unity.inputsystem/InputSystem/Actions/InputActionStateMonitorIndex.cs.meta new file mode 100644 index 0000000000..20f49dbd53 --- /dev/null +++ b/Packages/com.unity.inputsystem/InputSystem/Actions/InputActionStateMonitorIndex.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f70e12521b7643ea89b31ca4387b019f +timeCreated: 1775829844 \ No newline at end of file diff --git a/Packages/com.unity.inputsystem/InputSystem/Editor/Analytics/InputBuildAnalytic.cs b/Packages/com.unity.inputsystem/InputSystem/Editor/Analytics/InputBuildAnalytic.cs index 61256e5a78..1f206f43ef 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Editor/Analytics/InputBuildAnalytic.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Editor/Analytics/InputBuildAnalytic.cs @@ -189,6 +189,7 @@ public InputBuildAnalyticData(BuildReport report, InputSettings settings, InputS supported_devices = settings.supportedDevices.ToArray(); disable_redundant_events_merging = settings.disableRedundantEventsMerging; shortcut_keys_consume_input = settings.shortcutKeysConsumeInput; + shortcut_keys_use_action_priority = settings.shortcutKeysUseActionPriority; feature_optimized_controls_enabled = settings.IsFeatureEnabled(InputFeatureNames.kUseOptimizedControls); feature_read_value_caching_enabled = settings.IsFeatureEnabled(InputFeatureNames.kUseReadValueCaching); @@ -308,6 +309,11 @@ public InputBuildAnalyticData(BuildReport report, InputSettings settings, InputS /// public bool shortcut_keys_consume_input; + /// + /// Represents + /// + public bool shortcut_keys_use_action_priority; + #endregion #region Feature flag settings diff --git a/Packages/com.unity.inputsystem/InputSystem/Editor/Settings/InputSettingsProvider.cs b/Packages/com.unity.inputsystem/InputSystem/Editor/Settings/InputSettingsProvider.cs index cb21d46051..575d73c9f5 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Editor/Settings/InputSettingsProvider.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Editor/Settings/InputSettingsProvider.cs @@ -24,7 +24,7 @@ internal class InputSettingsProvider : SettingsProvider, IDisposable private static readonly string[] kInputSettingsKeywords = { - "Input", "Action", "Controls", "Gamepad", "Keyboard", "Mouse", "Touch" + "Input", "Action", "Controls", "Gamepad", "Keyboard", "Mouse", "Touch", "Shortcut", "Priority", "Complexity", "Consumption" }; public static void Open() @@ -171,15 +171,29 @@ public override void OnGUI(string searchContext) EditorGUILayout.LabelField("Improved Shortcut Support", EditorStyles.boldLabel); EditorGUILayout.Space(); EditorGUILayout.PropertyField(m_ShortcutKeysConsumeInputs, m_ShortcutKeysConsumeInputsContent); - if (m_ShortcutKeysConsumeInputs.boolValue) - EditorGUILayout.HelpBox("Please note that enabling Improved Shortcut Support will cause actions with composite bindings to consume input and block any other actions which are enabled and sharing the same controls. " - + "Input consumption is performed in priority order, with the action containing the greatest number of bindings checked first. " - + "Therefore actions requiring fewer keypresses will not be triggered if an action using more keypresses is triggered and has overlapping controls. " - + "This works for shortcut keys, however in other cases this might not give the desired result, especially where there are actions with the exact same number of composite controls, in which case it is non-deterministic which action will be triggered. " - + "These conflicts may occur even between actions which belong to different Action Maps e.g. if using an UIInputModule with the Arrow Keys bound to the Navigate Action in the UI Action Map, this would interfere with other Action Maps using those keys. " - + "However conflicts would not occur between actions which belong to different Action Assets. " - + "Since event consumption only occurs for enabled actions, you can resolve unexpected issues by ensuring that only those Actions or Action Maps that are relevant to your game's current context are enabled. Enabling or disabling actions as your game or application moves between different contexts. " - , MessageType.None); + EditorGUILayout.PropertyField(m_ShortcutKeysUseActionPriority, m_ShortcutKeysUseActionPriorityContent); + if (m_ShortcutKeysUseActionPriority.boolValue && m_ShortcutKeysConsumeInputs.boolValue) + { + EditorGUILayout.HelpBox( + "Action priority shortcut resolution is enabled and takes precedence. Complexity-based consumption is also toggled on but will not be used until action priority is turned off.", + MessageType.Info); + } + else if (m_ShortcutKeysUseActionPriority.boolValue) + { + EditorGUILayout.HelpBox( + "When several enabled actions are bound to the same control, the one with the highest Priority is evaluated first and consumes the input, so lower-priority actions don't also trigger from that event. " + + "Set each action's Priority in the Input Actions editor. Priority values stay saved on the asset even when Action Priority Shortcut Resolution is turned off.", + MessageType.None); + } + else if (m_ShortcutKeysConsumeInputs.boolValue) + { + EditorGUILayout.HelpBox( + "Overlapping shortcuts are resolved by composite complexity: an action whose composite has more parts (e.g. Ctrl+Shift+S) wins over one with fewer parts — or a plain binding — on the same control, and consumes the input so the simpler action doesn't also fire. " + + "Two composites of equal complexity have no guaranteed order between them. " + + "Resolution applies across action maps within the same asset (for example UI navigation vs. gameplay on the same keys), but never across separate action assets. " + + "Only enabled actions consume input, so disable maps or actions you don't need in the current context to avoid unexpected behaviour.", + MessageType.None); + } if (EditorGUI.EndChangeCheck()) Apply(); @@ -300,6 +314,7 @@ private void InitializeWithCurrentSettings() m_TapRadius = m_SettingsObject.FindProperty("m_TapRadius"); m_MultiTapDelayTime = m_SettingsObject.FindProperty("m_MultiTapDelayTime"); m_ShortcutKeysConsumeInputs = m_SettingsObject.FindProperty("m_ShortcutKeysConsumeInputs"); + m_ShortcutKeysUseActionPriority = m_SettingsObject.FindProperty("m_ShortcutKeysUseActionPriority"); m_UpdateModeContent = new GUIContent("Update Mode", "When should the Input System be updated?"); #if UNITY_INPUT_SYSTEM_PLATFORM_SCROLL_DELTA @@ -330,7 +345,8 @@ private void InitializeWithCurrentSettings() m_DefaultHoldTimeContent = new GUIContent("Default Hold Time", "Default duration to be used for Hold interactions."); m_TapRadiusContent = new GUIContent("Tap Radius", "Maximum distance between two finger taps on a touch screen device allowed for the system to consider this a tap of the same touch (as opposed to a new touch)."); m_MultiTapDelayTimeContent = new GUIContent("MultiTap Delay Time", "Default delay to be allowed between taps for MultiTap interactions. Also used by by touch devices to count multi taps."); - m_ShortcutKeysConsumeInputsContent = new GUIContent("Enable Input Consumption", "Actions are exclusively triggered and will consume/block other actions sharing the same input. E.g. when pressing the 'Shift+B' keys, the associated action would trigger but any action bound to just the 'B' key would be prevented from triggering at the same time."); + m_ShortcutKeysConsumeInputsContent = new GUIContent("Complexity Consumption", "When enabled (and action priority resolution is off), composite bindings consume overlapping input using binding-chain depth (complexity), not per-action priority."); + m_ShortcutKeysUseActionPriorityContent = new GUIContent("Priority Consumption", "When enabled, overlapping actions are resolved using each action's Priority value. This overrides complexity-based resolution even if it is also enabled."); // Initialize ReorderableList for list of supported devices. var supportedDevicesProperty = m_SettingsObject.FindProperty("m_SupportedDevices"); @@ -442,6 +458,7 @@ private static string[] FindInputSettingsInProject() [NonSerialized] private SerializedProperty m_TapRadius; [NonSerialized] private SerializedProperty m_MultiTapDelayTime; [NonSerialized] private SerializedProperty m_ShortcutKeysConsumeInputs; + [NonSerialized] private SerializedProperty m_ShortcutKeysUseActionPriority; [NonSerialized] private ReorderableList m_SupportedDevices; [NonSerialized] private string[] m_AvailableInputSettingsAssets; @@ -468,6 +485,7 @@ private static string[] FindInputSettingsInProject() private GUIContent m_TapRadiusContent; private GUIContent m_MultiTapDelayTimeContent; private GUIContent m_ShortcutKeysConsumeInputsContent; + private GUIContent m_ShortcutKeysUseActionPriorityContent; [NonSerialized] private InputSettingsiOSProvider m_iOSProvider; diff --git a/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/Commands/Commands.cs b/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/Commands/Commands.cs index 3cfcf2df44..8d9dce4923 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/Commands/Commands.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/Commands/Commands.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using UnityEditor; +using UnityEngine; using UnityEngine.InputSystem.Editor.Lists; using UnityEngine.InputSystem.Utilities; @@ -534,6 +535,18 @@ public static Command SetCompositeBindingPartName(SerializedInputBinding binding }; } + public static Command ChangeActionPriority(SerializedInputAction inputAction, int priority) + { + return (in InputActionsEditorState state) => + { + var priorityProperty = inputAction.wrappedProperty.FindPropertyRelative(nameof(InputAction.m_Priority)); + priorityProperty.intValue = InputAction.ClampPriority(priority); + state.serializedObject.ApplyModifiedProperties(); + state.m_Analytics?.RegisterActionEdit(); + return state; + }; + } + public static Command ChangeActionType(SerializedInputAction inputAction, InputActionType newValue) { return (in InputActionsEditorState state) => diff --git a/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/InputActionsEditorConstants.cs b/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/InputActionsEditorConstants.cs index c0c2c07c3b..1d25d69635 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/InputActionsEditorConstants.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/InputActionsEditorConstants.cs @@ -34,6 +34,12 @@ internal static class InputActionsEditorConstants + "immediately trigger if any of its bound controls are currently in a non-default state. " + "This check happens implicitly for Value actions but can be explicitly enabled for Button and Pass-Through actions."; + public static readonly string ActionPriorityTooltip = + "Priority for this action when several bindings share the same control. Applies to all bindings on the action. " + + $"Values are clamped to {InputAction.MinPriority}–{InputAction.MaxPriority} when set (unsigned 16-bit at runtime). When Action Priority Shortcut Resolution is enabled, " + + "higher values are ordered first; when the action performs, priority 0 does not mark the input event as handled " + + "for overlap suppression, while any value greater than 0 can."; + public struct CommandEvents { public const string Rename = "Rename"; diff --git a/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/SerializedInputAction.cs b/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/SerializedInputAction.cs index 125c41a6a8..aa2a870fee 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/SerializedInputAction.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/SerializedInputAction.cs @@ -20,6 +20,7 @@ public SerializedInputAction(SerializedProperty serializedProperty) processors = serializedProperty.FindPropertyRelative(nameof(InputAction.m_Processors)).stringValue; propertyPath = wrappedProperty.propertyPath; initialStateCheck = ReadInitialStateCheck(serializedProperty); + priority = serializedProperty.FindPropertyRelative(nameof(InputAction.m_Priority)).intValue; actionTypeTooltip = serializedProperty.FindPropertyRelative(nameof(InputAction.m_Type)).GetTooltip(); expectedControlTypeTooltip = serializedProperty.FindPropertyRelative(nameof(InputAction.m_ExpectedControlType)).GetTooltip(); } @@ -32,6 +33,7 @@ public SerializedInputAction(SerializedProperty serializedProperty) public string processors { get; } public string propertyPath { get; } public bool initialStateCheck { get; } + public int priority { get; } public string actionTypeTooltip { get; } public string expectedControlTypeTooltip { get; } public SerializedProperty wrappedProperty { get; } @@ -60,6 +62,7 @@ public bool Equals(SerializedInputAction other) && interactions == other.interactions && processors == other.processors && initialStateCheck == other.initialStateCheck + && priority == other.priority && actionTypeTooltip == other.actionTypeTooltip && expectedControlTypeTooltip == other.expectedControlTypeTooltip && propertyPath == other.propertyPath; @@ -79,6 +82,7 @@ public override int GetHashCode() hashCode.Add(interactions); hashCode.Add(processors); hashCode.Add(initialStateCheck); + hashCode.Add(priority); hashCode.Add(actionTypeTooltip); hashCode.Add(expectedControlTypeTooltip); hashCode.Add(propertyPath); diff --git a/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/Views/ActionPropertiesView.cs b/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/Views/ActionPropertiesView.cs index 89bcf81f3e..45ab346662 100644 --- a/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/Views/ActionPropertiesView.cs +++ b/Packages/com.unity.inputsystem/InputSystem/Editor/UITKAssetEditor/Views/ActionPropertiesView.cs @@ -89,6 +89,27 @@ public override void RedrawUI((SerializedInputAction ? , List) viewState Dispatch(Commands.ChangeActionControlType(inputAction, 0)); } + var showPriority = InputSystem.settings != null && InputSystem.settings.shortcutKeysUseActionPriority; + + var priorityField = new IntegerField("Priority") + { + tooltip = InputActionsEditorConstants.ActionPriorityTooltip + }; + var priorityLabel = priorityField.Q