From e20825ea696a8ec0b4e89a13b0329f21e6e610f2 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 11:18:14 +0900 Subject: [PATCH 1/2] Extract keyboard input response construction Move interrupted and timed-out response mapping into a stateless factory so the keyboard use case no longer owns wire-visible response details. Characterization tests preserve pause-point evidence and nullable press-edge semantics. --- ...oardInputSimulationResponseFactoryTests.cs | 130 ++++++++++++++++++ ...nputSimulationResponseFactoryTests.cs.meta | 11 ++ .../KeyboardInputSimulationResponseFactory.cs | 101 ++++++++++++++ ...oardInputSimulationResponseFactory.cs.meta | 11 ++ .../SimulateKeyboardUseCase.cs | 116 ++++------------ 5 files changed, 277 insertions(+), 92 deletions(-) create mode 100644 Assets/Tests/PlayMode/KeyboardInputSimulationResponseFactoryTests.cs create mode 100644 Assets/Tests/PlayMode/KeyboardInputSimulationResponseFactoryTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputSimulationResponseFactory.cs create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputSimulationResponseFactory.cs.meta diff --git a/Assets/Tests/PlayMode/KeyboardInputSimulationResponseFactoryTests.cs b/Assets/Tests/PlayMode/KeyboardInputSimulationResponseFactoryTests.cs new file mode 100644 index 0000000000..744d8e36cf --- /dev/null +++ b/Assets/Tests/PlayMode/KeyboardInputSimulationResponseFactoryTests.cs @@ -0,0 +1,130 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using System; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.PlayMode +{ + /// + /// Characterizes wire-visible keyboard input response construction before factory extraction. + /// + [TestFixture] + public sealed class KeyboardInputSimulationResponseFactoryTests + { + private readonly DateTime _nowUtc = new(2026, 7, 9, 0, 0, 0, DateTimeKind.Utc); + + [SetUp] + public void SetUp() + { + UloopPausePointRegistry.ConfigureForTests(new FakePauseController(), () => _nowUtc); + } + + [TearDown] + public void TearDown() + { + UloopPausePointRegistry.ResetForTests(); + } + + /// + /// Verifies interrupted press responses preserve edge observations and project every Pause Point hit. + /// + [TestCase(true)] + [TestCase(false)] + public void InterruptedKeyResult_WithMultiplePausePointHits_MapsPauseEvidenceAndPressEdge( + bool pressEdgeObserved) + { + UloopPausePointRegistry.Enable("first-hit", 30); + UloopPausePointRegistry.Enable("latest-hit", 30); + UloopPausePoint.Pause("first-hit"); + UloopPausePoint.Pause("latest-hit"); + + SimulateKeyboardResponse response = KeyboardInputSimulationResponseFactory.InterruptedKeyResult( + UnityCliLoopKeyboardAction.Press, + "Space", + pressEdgeObserved); + + Assert.That(response.Success, Is.True); + Assert.That( + response.Message, + Is.EqualTo( + "Keyboard input stopped because Unity paused during Pause Point inspection. Key 'Space' was released from Unity CLI Loop bookkeeping.")); + Assert.That(response.Action, Is.EqualTo(UnityCliLoopKeyboardAction.Press.ToString())); + Assert.That(response.KeyName, Is.EqualTo("Space")); + Assert.That(response.InterruptedByPausePoint, Is.True); + Assert.That(response.PressEdgeObserved, Is.EqualTo(pressEdgeObserved)); + Assert.That(response.PausePointId, Is.EqualTo("latest-hit")); + Assert.That(response.PausePointHitCount, Is.EqualTo(1)); + Assert.That(response.PausePointHits, Has.Count.EqualTo(2)); + Assert.That(response.PausePointHits![0].Id, Is.EqualTo("first-hit")); + Assert.That(response.PausePointHits[1].Id, Is.EqualTo("latest-hit")); + } + + /// + /// Verifies interrupted key-up responses preserve the absent press edge and project a single Pause Point hit. + /// + [Test] + public void InterruptedKeyResult_ForKeyUp_MapsNullPressEdgeAndSinglePausePointHit() + { + UloopPausePointRegistry.Enable("key-up-hit", 30); + UloopPausePoint.Pause("key-up-hit"); + + SimulateKeyboardResponse response = KeyboardInputSimulationResponseFactory.InterruptedKeyResult( + UnityCliLoopKeyboardAction.KeyUp, + "Enter", + null); + + Assert.That(response.Success, Is.True); + Assert.That(response.Action, Is.EqualTo(UnityCliLoopKeyboardAction.KeyUp.ToString())); + Assert.That(response.KeyName, Is.EqualTo("Enter")); + Assert.That(response.InterruptedByPausePoint, Is.True); + Assert.That(response.PressEdgeObserved, Is.Null); + Assert.That(response.PausePointId, Is.EqualTo("key-up-hit")); + Assert.That(response.PausePointHitCount, Is.EqualTo(1)); + Assert.That(response.PausePointHits, Has.Count.EqualTo(1)); + Assert.That(response.PausePointHits![0].Id, Is.EqualTo("key-up-hit")); + } + + /// + /// Verifies timed-out key responses preserve the action, key name, and timeout failure fields. + /// + [Test] + public void TimedOutKeyResult_WithActionAndKeyName_MapsFailureResponse() + { + SimulateKeyboardResponse response = KeyboardInputSimulationResponseFactory.TimedOutKeyResult( + UnityCliLoopKeyboardAction.KeyDown, + "W"); + + Assert.That(response.Success, Is.False); + Assert.That( + response.Message, + Is.EqualTo( + "Keyboard input timed out while waiting for Unity Editor update. Key 'W' cleanup is queued for the next Editor tick.")); + Assert.That(response.Action, Is.EqualTo(UnityCliLoopKeyboardAction.KeyDown.ToString())); + Assert.That(response.KeyName, Is.EqualTo("W")); + Assert.That(response.InterruptedByPausePoint, Is.False); + Assert.That(response.PressEdgeObserved, Is.Null); + Assert.That(response.PausePointId, Is.Null); + Assert.That(response.PausePointHitCount, Is.Null); + Assert.That(response.PausePointHits, Is.Null); + } + + /// + /// Test double that records Pause Point state without pausing the Unity Editor. + /// + private sealed class FakePauseController : IUloopPausePointPauseController + { + public bool IsPlaying => true; + public bool IsPaused { get; private set; } + + public void Pause() + { + IsPaused = true; + } + } + } +} +#endif diff --git a/Assets/Tests/PlayMode/KeyboardInputSimulationResponseFactoryTests.cs.meta b/Assets/Tests/PlayMode/KeyboardInputSimulationResponseFactoryTests.cs.meta new file mode 100644 index 0000000000..c07baf9453 --- /dev/null +++ b/Assets/Tests/PlayMode/KeyboardInputSimulationResponseFactoryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a66b1bde64734e4f9b31d409f0d230a8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputSimulationResponseFactory.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputSimulationResponseFactory.cs new file mode 100644 index 0000000000..e80c101cd2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputSimulationResponseFactory.cs @@ -0,0 +1,101 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using System.Collections.Generic; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Creates wire-visible responses for keyboard input simulation outcomes. + /// + internal static class KeyboardInputSimulationResponseFactory + { + // pressEdgeObserved stays nullable because KeyUp has no press edge to report; + // Press/KeyDown must pass their observation so pause-point interruptions (the + // most common E2E path) do not silently drop the field. + internal static SimulateKeyboardResponse InterruptedKeyResult( + UnityCliLoopKeyboardAction action, + string keyName, + bool? pressEdgeObserved) + { + SimulateKeyboardResponse result = new() + { + Success = true, + Message = $"Keyboard input stopped because Unity paused during Pause Point inspection. Key '{keyName}' was released from Unity CLI Loop bookkeeping.", + Action = action.ToString(), + KeyName = keyName, + InterruptedByPausePoint = true, + PressEdgeObserved = pressEdgeObserved + }; + AttachPausePointHit(result); + return result; + } + + internal static SimulateKeyboardResponse TimedOutKeyResult( + UnityCliLoopKeyboardAction action, + string keyName) + { + return new SimulateKeyboardResponse + { + Success = false, + Message = $"Keyboard input timed out while waiting for Unity Editor update. Key '{keyName}' cleanup is queued for the next Editor tick.", + Action = action.ToString(), + KeyName = keyName + }; + } + + private static void AttachPausePointHit(SimulateKeyboardResponse result) + { + if (result == null) + { + Debug.Assert(false, "result must not be null"); + return; + } + + UloopPausePointSnapshot? snapshot = UloopPausePointRegistry.GetLatestHitSnapshot(); + if (snapshot == null) + { + return; + } + + if (!snapshot.IsHit) + { + return; + } + + string? snapshotId = snapshot.Id; + if (string.IsNullOrEmpty(snapshotId)) + { + return; + } + + result.PausePointId = snapshotId; + result.PausePointHitCount = snapshot.HitCount; + result.PausePointHits = CollectPausePointHits(); + } + + // One input can hit several markers in the same frame; the representative + // PausePointId alone forced agents into extra status calls to find the others. + private static List CollectPausePointHits() + { + List hits = new(); + foreach (UloopPausePointSnapshot snapshot in UloopPausePointRegistry.GetHitSnapshots()) + { + if (!snapshot.IsHit || string.IsNullOrEmpty(snapshot.Id)) + { + continue; + } + hits.Add(new UnityCliLoopPausePointHit + { + Id = snapshot.Id, + HitCount = snapshot.HitCount + }); + } + return hits; + } + } +} +#endif diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputSimulationResponseFactory.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputSimulationResponseFactory.cs.meta new file mode 100644 index 0000000000..60c77ebd48 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputSimulationResponseFactory.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 78948d1855194e8cb4c9fde19b01776d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index 8700d0b39d..281131d244 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -1,6 +1,5 @@ #nullable enable using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using UnityEditor; @@ -237,12 +236,17 @@ private async Task ExecutePress( if (waitOutcome == InputSimulationWaitOutcome.Paused) { - return InterruptedKeyResult(UnityCliLoopKeyboardAction.Press, keyName, pressEdgeObserved); + return KeyboardInputSimulationResponseFactory.InterruptedKeyResult( + UnityCliLoopKeyboardAction.Press, + keyName, + pressEdgeObserved); } if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - return TimedOutKeyResult(UnityCliLoopKeyboardAction.Press, keyName); + return KeyboardInputSimulationResponseFactory.TimedOutKeyResult( + UnityCliLoopKeyboardAction.Press, + keyName); } string durationText = duration > 0f ? $" for {InputSimulationDurationFormatter.FormatSeconds(duration)}s" : ""; @@ -320,12 +324,17 @@ private async Task ExecuteKeyDown(Keyboard keyboard, K if (waitOutcome == InputSimulationWaitOutcome.Paused) { - return InterruptedKeyResult(UnityCliLoopKeyboardAction.KeyDown, keyName, pressEdgeObserved); + return KeyboardInputSimulationResponseFactory.InterruptedKeyResult( + UnityCliLoopKeyboardAction.KeyDown, + keyName, + pressEdgeObserved); } if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - return TimedOutKeyResult(UnityCliLoopKeyboardAction.KeyDown, keyName); + return KeyboardInputSimulationResponseFactory.TimedOutKeyResult( + UnityCliLoopKeyboardAction.KeyDown, + keyName); } string keyDownEdgeText = pressEdgeObserved @@ -362,7 +371,9 @@ private async Task ExecuteKeyUp(Keyboard keyboard, Key if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) { ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, false); - return TimedOutKeyResult(UnityCliLoopKeyboardAction.KeyUp, keyName); + return KeyboardInputSimulationResponseFactory.TimedOutKeyResult( + UnityCliLoopKeyboardAction.KeyUp, + keyName); } await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); @@ -373,12 +384,17 @@ private async Task ExecuteKeyUp(Keyboard keyboard, Key .ConfigureAwait(false); if (waitOutcome == InputSimulationWaitOutcome.Paused) { - return InterruptedKeyResult(UnityCliLoopKeyboardAction.KeyUp, keyName, null); + return KeyboardInputSimulationResponseFactory.InterruptedKeyResult( + UnityCliLoopKeyboardAction.KeyUp, + keyName, + null); } if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - return TimedOutKeyResult(UnityCliLoopKeyboardAction.KeyUp, keyName); + return KeyboardInputSimulationResponseFactory.TimedOutKeyResult( + UnityCliLoopKeyboardAction.KeyUp, + keyName); } return new SimulateKeyboardResponse @@ -399,90 +415,6 @@ private static string NormalizeKeyName(string keyName) return keyName; } - // pressEdgeObserved stays nullable because KeyUp has no press edge to report; - // Press/KeyDown must pass their observation so pause-point interruptions (the - // most common E2E path) do not silently drop the field. - private static SimulateKeyboardResponse InterruptedKeyResult( - UnityCliLoopKeyboardAction action, - string keyName, - bool? pressEdgeObserved) - { - SimulateKeyboardResponse result = new() - { - Success = true, - Message = $"Keyboard input stopped because Unity paused during Pause Point inspection. Key '{keyName}' was released from Unity CLI Loop bookkeeping.", - Action = action.ToString(), - KeyName = keyName, - InterruptedByPausePoint = true, - PressEdgeObserved = pressEdgeObserved - }; - AttachPausePointHit(result); - return result; - } - - private static SimulateKeyboardResponse TimedOutKeyResult( - UnityCliLoopKeyboardAction action, - string keyName) - { - return new SimulateKeyboardResponse - { - Success = false, - Message = $"Keyboard input timed out while waiting for Unity Editor update. Key '{keyName}' cleanup is queued for the next Editor tick.", - Action = action.ToString(), - KeyName = keyName - }; - } - - private static void AttachPausePointHit(SimulateKeyboardResponse result) - { - if (result == null) - { - Debug.Assert(false, "result must not be null"); - return; - } - - UloopPausePointSnapshot? snapshot = UloopPausePointRegistry.GetLatestHitSnapshot(); - if (snapshot == null) - { - return; - } - - if (!snapshot.IsHit) - { - return; - } - - string? snapshotId = snapshot.Id; - if (string.IsNullOrEmpty(snapshotId)) - { - return; - } - - result.PausePointId = snapshotId; - result.PausePointHitCount = snapshot.HitCount; - result.PausePointHits = CollectPausePointHits(); - } - - // One input can hit several markers in the same frame; the representative - // PausePointId alone forced agents into extra status calls to find the others. - private static List CollectPausePointHits() - { - List hits = new(); - foreach (UloopPausePointSnapshot snapshot in UloopPausePointRegistry.GetHitSnapshots()) - { - if (!snapshot.IsHit || string.IsNullOrEmpty(snapshot.Id)) - { - continue; - } - hits.Add(new UnityCliLoopPausePointHit - { - Id = snapshot.Id, - HitCount = snapshot.HitCount - }); - } - return hits; - } - private static async Task FinalizePressOverlay(CancellationToken ct) { await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); From b241fd712bf8dc13f7707815777bd0fbd351c3bb Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 11:21:12 +0900 Subject: [PATCH 2/2] Extract keyboard input main-thread cleanup Move keyboard state restoration, timeout cleanup, and press overlay finalization into a stateless helper so the use case focuses on orchestration. Track the helper with the async cancellation-token guard. --- .../Editor/StaticFacadeStateGuardTests.cs | 3 +- .../KeyboardInputMainThreadCleanup.cs | 163 ++++++++++++++++ .../KeyboardInputMainThreadCleanup.cs.meta | 11 ++ .../SimulateKeyboardUseCase.cs | 174 +++--------------- 4 files changed, 201 insertions(+), 150 deletions(-) create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs create mode 100644 Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs.meta diff --git a/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs b/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs index f8c28b6c42..95d3409156 100644 --- a/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs +++ b/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs @@ -65,7 +65,8 @@ public sealed class StaticFacadeStateGuardTests "Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputMainThreadCleanup.cs", "Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputPressActionExecutor.cs", "Packages/src/Editor/FirstPartyTools/SimulateMouseInput/MouseInputMotionActionExecutor.cs", - "Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs" + "Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs", + "Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs" }; private static readonly Dictionary OverloadGuardMethodsByPath = new Dictionary diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs new file mode 100644 index 0000000000..eab92fb246 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs @@ -0,0 +1,163 @@ +#if ULOOP_HAS_INPUT_SYSTEM +#nullable enable +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; +using UnityEngine; +using UnityEngine.InputSystem; + +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Restores keyboard device and overlay state on the Unity main thread after simulation ends. + /// + internal static class KeyboardInputMainThreadCleanup + { + internal static async Task FinalizePressOverlay(CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + if (ct.IsCancellationRequested) + { + SimulateKeyboardOverlayState.ClearPress(); + return; + } + + SimulateKeyboardOverlayState.ReleasePress(); + await EditorFrameWaiter.WaitFramesOrTimeoutAsync( + 1, + UnityCliLoopConstants.EDITOR_FRAME_WAIT_TIMEOUT_MS, + CancellationToken.None).ConfigureAwait(false); + } + + internal static async Task RollbackHeldKey( + Keyboard keyboard, + Key key, + string keyName, + CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + InputSimulationWaitOutcome releaseOutcome = + await ReleaseKeyStateIfPossible(keyboard, key, ct).ConfigureAwait(false); + if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, false); + return releaseOutcome; + } + + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + KeyboardKeyState.SetKeyUp(key); + SimulateKeyboardOverlayState.RemoveHeldKey(keyName); + return releaseOutcome; + } + + internal static async Task ReleaseKeyStateIfPossible( + Keyboard keyboard, + Key key, + CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + if (!CanInjectKeyboardState(keyboard)) + { + return InputSimulationWaitOutcome.Completed; + } + + if (EditorApplication.isPaused) + { + ReleaseKeyStateImmediately(keyboard, key); + return InputSimulationWaitOutcome.Completed; + } + + InputSimulationWaitOutcome releaseOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => KeyboardKeyState.SetKeyState(keyboard, key, false), + ct).ConfigureAwait(false); + if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleReleaseKeyStateImmediately(keyboard, key); + } + + return releaseOutcome; + } + + private static void ScheduleReleaseKeyStateImmediately(Keyboard keyboard, Key key) + { + ReleaseKeyStateImmediatelyOnMainThreadAsync(keyboard, key, CancellationToken.None).Forget(); + } + + private static async Task ReleaseKeyStateImmediatelyOnMainThreadAsync( + Keyboard keyboard, + Key key, + CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + ReleaseKeyStateImmediately(keyboard, key); + } + + private static void ReleaseKeyStateImmediately(Keyboard keyboard, Key key) + { + Debug.Assert(CanInjectKeyboardState(keyboard), "keyboard state can only be released while PlayMode has a keyboard"); + if (!CanInjectKeyboardState(keyboard)) + { + return; + } + + KeyboardKeyState.SetKeyState(keyboard, key, false); + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); + } + + internal static void ScheduleTimedOutPressCleanup(Keyboard keyboard, Key key, bool pressWasApplied) + { + CleanupTimedOutPressAsync(keyboard, key, pressWasApplied, CancellationToken.None).Forget(); + } + + private static async Task CleanupTimedOutPressAsync( + Keyboard keyboard, + Key key, + bool pressWasApplied, + CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + if (pressWasApplied) + { + await ReleaseKeyStateIfPossible(keyboard, key, ct).ConfigureAwait(false); + } + + KeyboardKeyState.UnregisterTransientKey(key); + SimulateKeyboardOverlayState.ClearPress(); + } + + internal static void ScheduleTimedOutHeldKeyCleanup( + Keyboard keyboard, + Key key, + string keyName, + bool keyWasApplied) + { + CleanupTimedOutHeldKeyAsync(keyboard, key, keyName, keyWasApplied, CancellationToken.None).Forget(); + } + + private static async Task CleanupTimedOutHeldKeyAsync( + Keyboard keyboard, + Key key, + string keyName, + bool keyWasApplied, + CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + if (keyWasApplied) + { + await ReleaseKeyStateIfPossible(keyboard, key, ct).ConfigureAwait(false); + } + + KeyboardKeyState.SetKeyUp(key); + SimulateKeyboardOverlayState.RemoveHeldKey(keyName); + } + + private static bool CanInjectKeyboardState(Keyboard keyboard) + { + return EditorApplication.isPlaying && keyboard != null; + } + } +} +#endif diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs.meta new file mode 100644 index 0000000000..811c8e71bc --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4528fdd6d18c440f8332a3ddec94d376 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index 281131d244..9965251560 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -2,8 +2,6 @@ using System; using System.Threading; using System.Threading.Tasks; -using UnityEditor; -using UnityEngine; #if ULOOP_HAS_INPUT_SYSTEM using UnityEngine.InputSystem; #endif @@ -205,16 +203,22 @@ private async Task ExecutePress( InputSystem.onAfterUpdate -= pressEdgeMonitor; if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - ScheduleTimedOutPressCleanup(keyboard, key, pressWasApplied); + KeyboardInputMainThreadCleanup.ScheduleTimedOutPressCleanup( + keyboard, + key, + pressWasApplied); } else if (pressWasApplied) { InputSimulationWaitOutcome releaseOutcome = - await ReleaseKeyStateIfPossible(keyboard, key, CancellationToken.None).ConfigureAwait(false); + await KeyboardInputMainThreadCleanup.ReleaseKeyStateIfPossible( + keyboard, + key, + CancellationToken.None).ConfigureAwait(false); if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) { waitOutcome = InputSimulationWaitOutcome.TimedOut; - ScheduleTimedOutPressCleanup(keyboard, key, false); + KeyboardInputMainThreadCleanup.ScheduleTimedOutPressCleanup(keyboard, key, false); } else if (waitOutcome == InputSimulationWaitOutcome.Paused) { @@ -224,7 +228,7 @@ private async Task ExecutePress( else { KeyboardKeyState.UnregisterTransientKey(key); - await FinalizePressOverlay(ct).ConfigureAwait(false); + await KeyboardInputMainThreadCleanup.FinalizePressOverlay(ct).ConfigureAwait(false); } } else @@ -309,12 +313,20 @@ private async Task ExecuteKeyDown(Keyboard keyboard, K InputSystem.onAfterUpdate -= keyDownEdgeMonitor; if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, keyDownApplied); + KeyboardInputMainThreadCleanup.ScheduleTimedOutHeldKeyCleanup( + keyboard, + key, + keyName, + keyDownApplied); } else if (keyDownApplied && !committed) { InputSimulationWaitOutcome rollbackOutcome = - await RollbackHeldKey(keyboard, key, keyName, CancellationToken.None).ConfigureAwait(false); + await KeyboardInputMainThreadCleanup.RollbackHeldKey( + keyboard, + key, + keyName, + CancellationToken.None).ConfigureAwait(false); if (rollbackOutcome == InputSimulationWaitOutcome.TimedOut) { waitOutcome = InputSimulationWaitOutcome.TimedOut; @@ -366,11 +378,14 @@ private async Task ExecuteKeyUp(Keyboard keyboard, Key } InputSimulationWaitOutcome releaseOutcome = - await ReleaseKeyStateIfPossible(keyboard, key, CancellationToken.None).ConfigureAwait(false); + await KeyboardInputMainThreadCleanup.ReleaseKeyStateIfPossible( + keyboard, + key, + CancellationToken.None).ConfigureAwait(false); if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) { - ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, false); + KeyboardInputMainThreadCleanup.ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, false); return KeyboardInputSimulationResponseFactory.TimedOutKeyResult( UnityCliLoopKeyboardAction.KeyUp, keyName); @@ -415,145 +430,6 @@ private static string NormalizeKeyName(string keyName) return keyName; } - private static async Task FinalizePressOverlay(CancellationToken ct) - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); - if (ct.IsCancellationRequested) - { - SimulateKeyboardOverlayState.ClearPress(); - return; - } - - SimulateKeyboardOverlayState.ReleasePress(); - await EditorFrameWaiter.WaitFramesOrTimeoutAsync( - 1, - UnityCliLoopConstants.EDITOR_FRAME_WAIT_TIMEOUT_MS, - CancellationToken.None).ConfigureAwait(false); - } - - private static async Task RollbackHeldKey( - Keyboard keyboard, - Key key, - string keyName, - CancellationToken ct) - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); - InputSimulationWaitOutcome releaseOutcome = - await ReleaseKeyStateIfPossible(keyboard, key, ct).ConfigureAwait(false); - if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) - { - ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, false); - return releaseOutcome; - } - - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); - KeyboardKeyState.SetKeyUp(key); - SimulateKeyboardOverlayState.RemoveHeldKey(keyName); - return releaseOutcome; - } - - private static async Task ReleaseKeyStateIfPossible( - Keyboard keyboard, - Key key, - CancellationToken ct) - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); - if (!CanInjectKeyboardState(keyboard)) - { - return InputSimulationWaitOutcome.Completed; - } - - if (EditorApplication.isPaused) - { - ReleaseKeyStateImmediately(keyboard, key); - return InputSimulationWaitOutcome.Completed; - } - - InputSimulationWaitOutcome releaseOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( - () => KeyboardKeyState.SetKeyState(keyboard, key, false), - ct).ConfigureAwait(false); - if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) - { - ScheduleReleaseKeyStateImmediately(keyboard, key); - } - - return releaseOutcome; - } - - private static void ScheduleReleaseKeyStateImmediately(Keyboard keyboard, Key key) - { - ReleaseKeyStateImmediatelyOnMainThreadAsync(keyboard, key, CancellationToken.None).Forget(); - } - - private static async Task ReleaseKeyStateImmediatelyOnMainThreadAsync( - Keyboard keyboard, - Key key, - CancellationToken ct) - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); - ReleaseKeyStateImmediately(keyboard, key); - } - - private static void ReleaseKeyStateImmediately(Keyboard keyboard, Key key) - { - Debug.Assert(CanInjectKeyboardState(keyboard), "keyboard state can only be released while PlayMode has a keyboard"); - if (!CanInjectKeyboardState(keyboard)) - { - return; - } - - KeyboardKeyState.SetKeyState(keyboard, key, false); - InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); - } - - private static void ScheduleTimedOutPressCleanup(Keyboard keyboard, Key key, bool pressWasApplied) - { - CleanupTimedOutPressAsync(keyboard, key, pressWasApplied, CancellationToken.None).Forget(); - } - - private static async Task CleanupTimedOutPressAsync( - Keyboard keyboard, - Key key, - bool pressWasApplied, - CancellationToken ct) - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); - if (pressWasApplied) - { - await ReleaseKeyStateIfPossible(keyboard, key, ct).ConfigureAwait(false); - } - - KeyboardKeyState.UnregisterTransientKey(key); - SimulateKeyboardOverlayState.ClearPress(); - } - - private static void ScheduleTimedOutHeldKeyCleanup(Keyboard keyboard, Key key, string keyName, bool keyWasApplied) - { - CleanupTimedOutHeldKeyAsync(keyboard, key, keyName, keyWasApplied, CancellationToken.None).Forget(); - } - - private static async Task CleanupTimedOutHeldKeyAsync( - Keyboard keyboard, - Key key, - string keyName, - bool keyWasApplied, - CancellationToken ct) - { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); - if (keyWasApplied) - { - await ReleaseKeyStateIfPossible(keyboard, key, ct).ConfigureAwait(false); - } - - KeyboardKeyState.SetKeyUp(key); - SimulateKeyboardOverlayState.RemoveHeldKey(keyName); - } - - private static bool CanInjectKeyboardState(Keyboard keyboard) - { - return EditorApplication.isPlaying && keyboard != null; - } - // Runs inside InputSystem.onAfterUpdate. Editor updates are excluded because a press // consumed there never surfaces as wasPressedThisFrame to gameplay Update polling. private static bool IsGameplayPressEdgeVisible(Keyboard keyboard, Key key)