diff --git a/Assets/Tests/Editor/MouseUiMainThreadCleanupSchedulerTests.cs b/Assets/Tests/Editor/MouseUiMainThreadCleanupSchedulerTests.cs new file mode 100644 index 0000000000..c368686845 --- /dev/null +++ b/Assets/Tests/Editor/MouseUiMainThreadCleanupSchedulerTests.cs @@ -0,0 +1,56 @@ +using NUnit.Framework; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Characterizes synchronous mouse UI cleanup scheduling. + /// + [TestFixture] + public sealed class MouseUiMainThreadCleanupSchedulerTests + { + [TearDown] + public void TearDown() + { + SimulateMouseUiOverlayState.Clear(); + } + + /// + /// Verifies cleanup executes immediately when invoked from the Unity main thread. + /// + [Test] + public void ExecuteCleanupOnMainThread_AfterContextCapture_RunsImmediately() + { + MouseUiMainThreadCleanupScheduler scheduler = new(); + scheduler.CaptureMainThreadContext(); + bool cleanupRan = false; + + scheduler.ExecuteCleanupOnMainThread(() => cleanupRan = true); + + Assert.That(cleanupRan, Is.True); + } + + /// + /// Verifies queued overlay cleanup clears active visualization state on the main thread. + /// + [Test] + public void QueueOverlayClear_WithActiveOverlayState_ClearsState() + { + SimulateMouseUiOverlayState.Update( + MouseAction.Click, + new Vector2(10f, 20f), + null, + "Target", + new Vector2(100f, 200f)); + MouseUiMainThreadCleanupScheduler scheduler = new(); + scheduler.CaptureMainThreadContext(); + + scheduler.QueueOverlayClear(); + + Assert.That(SimulateMouseUiOverlayState.IsActive, Is.False); + } + } +} diff --git a/Assets/Tests/Editor/MouseUiMainThreadCleanupSchedulerTests.cs.meta b/Assets/Tests/Editor/MouseUiMainThreadCleanupSchedulerTests.cs.meta new file mode 100644 index 0000000000..f3b9247a7a --- /dev/null +++ b/Assets/Tests/Editor/MouseUiMainThreadCleanupSchedulerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 72fcc29882ac411f93a018981970fdc1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiEditorFrameWaiter.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiEditorFrameWaiter.cs new file mode 100644 index 0000000000..6aa39b1ce0 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiEditorFrameWaiter.cs @@ -0,0 +1,30 @@ +#nullable enable +using System.Threading; +using System.Threading.Tasks; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Waits for an Editor frame and restores execution to the Unity main thread. + /// + internal static class MouseUiEditorFrameWaiter + { + internal static async Task WaitForEditorFrameAndSwitchToMainThreadAsync(CancellationToken ct) + { + bool frameReady = await EditorFrameWaiter.WaitFramesOrTimeoutAsync( + 1, + UnityCliLoopConstants.EDITOR_FRAME_WAIT_TIMEOUT_MS, + ct).ConfigureAwait(false); + if (!frameReady) + { + return false; + } + + await MainThreadSwitcher.SwitchToMainThread(ct); + return true; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiEditorFrameWaiter.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiEditorFrameWaiter.cs.meta new file mode 100644 index 0000000000..545b44b73a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiEditorFrameWaiter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9a939b9693ec4f35ac24c6caae931084 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiMainThreadCleanupScheduler.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiMainThreadCleanupScheduler.cs new file mode 100644 index 0000000000..ca987ea499 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiMainThreadCleanupScheduler.cs @@ -0,0 +1,54 @@ +#nullable enable +using System; +using System.Threading; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Schedules mouse UI cleanup on the captured Unity main thread. + /// + internal sealed class MouseUiMainThreadCleanupScheduler + { + private SynchronizationContext? _mainThreadContext; + + internal void CaptureMainThreadContext() + { + _mainThreadContext = SynchronizationContext.Current; + Debug.Assert(_mainThreadContext != null, "Main thread synchronization context must be captured."); + } + + internal void QueueOverlayClear() + { + ExecuteCleanupOnMainThread(SimulateMouseUiOverlayState.Clear); + } + + internal void ExecuteCleanupOnMainThread(Action cleanup) + { + Debug.Assert(cleanup != null, "cleanup must not be null"); + if (cleanup == null) + { + throw new ArgumentNullException(nameof(cleanup)); + } + + if (MainThreadSwitcher.IsMainThread) + { + cleanup(); + return; + } + + SynchronizationContext? context = _mainThreadContext; + Debug.Assert(context != null, "Main thread synchronization context must be captured before cleanup."); + if (context == null) + { + throw new InvalidOperationException("Main thread synchronization context was not captured."); + } + + // Why: timeout continuations can run on timer threads while Unity objects must still be cleaned up on the Editor thread. + context.Post(_ => cleanup(), null); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiMainThreadCleanupScheduler.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiMainThreadCleanupScheduler.cs.meta new file mode 100644 index 0000000000..d51b7392b3 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiMainThreadCleanupScheduler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 862b3081b32e42bc817ece92f218fa64 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOverlayAnimator.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOverlayAnimator.cs new file mode 100644 index 0000000000..829f9685e5 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOverlayAnimator.cs @@ -0,0 +1,66 @@ +#nullable enable +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Animates the mouse UI overlay around simulated pointer actions. + /// + internal static class MouseUiOverlayAnimator + { + internal static async Task PlayExpandAnimation(CancellationToken ct) + { + SimulateMouseUiOverlay overlay = OverlayCanvasFactory.VisualizationCanvas.MouseUiOverlay; + + // Previous dissipate sets alpha to 0; restore before expand starts + overlay.SetAlpha(1f); + + float startTime = Time.realtimeSinceStartup; + float elapsed = 0f; + while (elapsed < SimulateMouseUiAnimationConstants.EXPAND_DURATION) + { + float t = elapsed / SimulateMouseUiAnimationConstants.EXPAND_DURATION; + overlay.SetCursorScale(Mathf.Lerp(SimulateMouseUiAnimationConstants.EXPAND_START_SCALE, 1f, t)); + bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + if (!frameReady) + { + return false; + } + await MainThreadSwitcher.SwitchToMainThread(ct); + elapsed = Time.realtimeSinceStartup - startTime; + } + overlay.SetCursorScale(1f); + return true; + } + + internal static async Task PlayDissipateAnimation(CancellationToken ct) + { + SimulateMouseUiOverlay overlay = OverlayCanvasFactory.VisualizationCanvas.MouseUiOverlay; + + float startTime = Time.realtimeSinceStartup; + float elapsed = 0f; + while (elapsed < SimulateMouseUiAnimationConstants.DISSIPATE_DURATION) + { + float t = elapsed / SimulateMouseUiAnimationConstants.DISSIPATE_DURATION; + overlay.SetCursorScale(Mathf.Lerp(1f, 0f, t)); + overlay.SetAlpha(Mathf.Lerp(1f, 0f, t)); + bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + if (!frameReady) + { + return false; + } + await MainThreadSwitcher.SwitchToMainThread(ct); + elapsed = Time.realtimeSinceStartup - startTime; + } + overlay!.SetCursorScale(0f); + overlay!.SetAlpha(0f); + SimulateMouseUiOverlayState.Clear(); + return true; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOverlayAnimator.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOverlayAnimator.cs.meta new file mode 100644 index 0000000000..6b160a0da2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOverlayAnimator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ab3c61773ce348259472cf424a950909 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationActivityLogger.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationActivityLogger.cs new file mode 100644 index 0000000000..7ba5091f81 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationActivityLogger.cs @@ -0,0 +1,42 @@ +#nullable enable +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Writes structured activity logs for mouse UI simulations. + /// + internal static class MouseUiSimulationActivityLogger + { + internal static void LogSimulationStart(MouseUiSimulationCommand parameters, string correlationId) + { + VibeLogger.LogInfo( + "simulate_mouse_start", + "Mouse simulation started", + new + { + Action = parameters.Action.ToString(), + X = parameters.X, + Y = parameters.Y, + BypassRaycast = parameters.BypassRaycast, + TargetPath = parameters.TargetPath, + DropTargetPath = parameters.DropTargetPath + }, + correlationId: correlationId + ); + } + + internal static void LogSimulationComplete( + MouseUiSimulationCommand parameters, + SimulateMouseUiResponse response, + string correlationId) + { + VibeLogger.LogInfo( + "simulate_mouse_complete", + $"Mouse simulation completed: {response.Message}", + new { Action = parameters.Action.ToString(), Success = response.Success }, + correlationId: correlationId + ); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationActivityLogger.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationActivityLogger.cs.meta new file mode 100644 index 0000000000..264de5c745 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiSimulationActivityLogger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5f39df77106c41f18c31870c6a94fdcc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs index debd1d01c1..cc65111861 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs @@ -1,5 +1,4 @@ #nullable enable -using System; using System.Threading; using System.Threading.Tasks; using UnityEditor; @@ -21,10 +20,7 @@ public class SimulateMouseUiUseCase // Wire-visible fragment of the paused preflight message; tests pin the composed string. public const string PausedActionDescription = "simulating UI input"; - private const float EXPAND_DURATION = SimulateMouseUiAnimationConstants.EXPAND_DURATION; - private const float EXPAND_START_SCALE = SimulateMouseUiAnimationConstants.EXPAND_START_SCALE; - private const float DISSIPATE_DURATION = SimulateMouseUiAnimationConstants.DISSIPATE_DURATION; - private SynchronizationContext? _mainThreadContext; + private readonly MouseUiMainThreadCleanupScheduler _mainThreadCleanupScheduler = new(); public async Task ExecuteAsync( SimulateMouseUiSchema request, @@ -36,7 +32,7 @@ public async Task ExecuteAsync( } ct.ThrowIfCancellationRequested(); - CaptureMainThreadContext(); + _mainThreadCleanupScheduler.CaptureMainThreadContext(); (MouseUiSimulationCommand? parameters, string? actionError) = MouseUiSimulationCommand.TryFromSchema(request); if (parameters == null) @@ -64,8 +60,8 @@ public async Task ExecuteAsync( Debug.Assert(eventSystem != null, "ValidateSimulationStart must reject a missing EventSystem."); EventSystem activeEventSystem = eventSystem!; - LogSimulationStart(parameters, correlationId); - EnsureOverlayExists(); + MouseUiSimulationActivityLogger.LogSimulationStart(parameters, correlationId); + OverlayCanvasFactory.EnsureExists(); SimulateMouseUiResponse? dragStateFailure = MouseUiSimulationValidator.ValidateActiveDragState(parameters); @@ -76,42 +72,11 @@ public async Task ExecuteAsync( SimulateMouseUiResponse response = await ExecuteMouseAction(parameters, activeEventSystem, ct).ConfigureAwait(false); - LogSimulationComplete(parameters, response, correlationId); + MouseUiSimulationActivityLogger.LogSimulationComplete(parameters, response, correlationId); return response; } - private static void LogSimulationStart(MouseUiSimulationCommand parameters, string correlationId) - { - VibeLogger.LogInfo( - "simulate_mouse_start", - "Mouse simulation started", - new - { - Action = parameters.Action.ToString(), - X = parameters.X, - Y = parameters.Y, - BypassRaycast = parameters.BypassRaycast, - TargetPath = parameters.TargetPath, - DropTargetPath = parameters.DropTargetPath - }, - correlationId: correlationId - ); - } - - private static void LogSimulationComplete( - MouseUiSimulationCommand parameters, - SimulateMouseUiResponse response, - string correlationId) - { - VibeLogger.LogInfo( - "simulate_mouse_complete", - $"Mouse simulation completed: {response.Message}", - new { Action = parameters.Action.ToString(), Success = response.Success }, - correlationId: correlationId - ); - } - private async Task ExecuteMouseAction( MouseUiSimulationCommand parameters, EventSystem eventSystem, @@ -146,11 +111,6 @@ private async Task ExecuteMouseAction( } } - private static void EnsureOverlayExists() - { - OverlayCanvasFactory.EnsureExists(); - } - // Input coordinates use top-left origin; Unity Screen space uses bottom-left origin. // Handles.GetMainGameViewSize() returns the Game view's target resolution (e.g. 1920x1080), // which matches the Canvas layout space — unlike Screen.height which returns the window pixel size. @@ -197,10 +157,10 @@ private async Task ExecuteClick( MouseAction.Click, inputPos, null, targetName, Handles.GetMainGameViewSize()); - bool expandCompleted = await PlayExpandAnimation(ct).ConfigureAwait(false); + bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); if (!expandCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.Click, inputPos, null, targetName); } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -208,10 +168,10 @@ private async Task ExecuteClick( // Fire click events after expand animation so the user sees where the click lands ExecutePointerClickEvents(resolvedTargets, pointerData); - bool dissipateCompleted = await PlayDissipateAnimation(ct).ConfigureAwait(false); + bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); if (!dissipateCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateClickResult(parameters, inputPos, targetName, hitTarget); } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -261,10 +221,10 @@ private async Task ExecuteLongPress( MouseAction.LongPress, inputPos, null, targetName, Handles.GetMainGameViewSize()); - bool expandCompleted = await PlayExpandAnimation(ct).ConfigureAwait(false); + bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); if (!expandCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.LongPress, inputPos, null, targetName); } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -279,10 +239,10 @@ private async Task ExecuteLongPress( while (elapsed < parameters.Duration) { SimulateMouseUiOverlayState.UpdateLongPressElapsed(elapsed); - bool frameReady = await WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); if (!frameReady) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.LongPress, inputPos, null, targetName); } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -295,15 +255,15 @@ private async Task ExecuteLongPress( // Ensure pointerUp fires even if the hold loop is cancelled if (shouldReleasePointer) { - ExecuteCleanupOnMainThread( + _mainThreadCleanupScheduler.ExecuteCleanupOnMainThread( () => ExecuteEvents.Execute(resolvedTargets.Target!, pointerData, ExecuteEvents.pointerUpHandler)); } } - bool dissipateCompleted = await PlayDissipateAnimation(ct).ConfigureAwait(false); + bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); if (!dissipateCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateLongPressResult(parameters, inputPos, targetName, hitTarget); } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -441,18 +401,18 @@ private async Task ExecuteDragOneShot( { SimulateMouseUiOverlayState.Update( MouseAction.Drag, inputStart, null, null, Handles.GetMainGameViewSize()); - bool expandCompleted = await PlayExpandAnimation(ct).ConfigureAwait(false); + bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); if (!expandCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.Drag, inputStart, inputEnd, null); } await MainThreadSwitcher.SwitchToMainThread(ct); - bool dissipateCompleted = await PlayDissipateAnimation(ct).ConfigureAwait(false); + bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); if (!dissipateCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.Drag, inputStart, inputEnd, null); } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -482,10 +442,10 @@ private async Task ExecuteDragOneShot( try { - bool expandCompleted = await PlayExpandAnimation(ct).ConfigureAwait(false); + bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); if (!expandCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.Drag, inputStart, inputEnd, targetName); } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -494,31 +454,31 @@ private async Task ExecuteDragOneShot( .ConfigureAwait(false); if (!dragCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.Drag, inputStart, inputEnd, targetName); } await MainThreadSwitcher.SwitchToMainThread(ct); - bool frameReady = await WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); if (!frameReady) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.Drag, inputStart, inputEnd, targetName); } await MainThreadSwitcher.SwitchToMainThread(ct); } finally { - ExecuteCleanupOnMainThread(() => FinalizeDrag(pointerData, target, explicitDropTarget)); + _mainThreadCleanupScheduler.ExecuteCleanupOnMainThread(() => FinalizeDrag(pointerData, target, explicitDropTarget)); } SimulateMouseUiOverlayState.Update( MouseAction.Drag, inputEnd, inputStart, targetName, Handles.GetMainGameViewSize()); - bool completedDissipate = await PlayDissipateAnimation(ct).ConfigureAwait(false); + bool completedDissipate = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); if (!completedDissipate) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateDragResult(parameters, inputStart, inputEnd, targetName); } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -575,7 +535,7 @@ private async Task InterpolateDragPosition( if (duration <= 0f) { - bool frameReady = await WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); if (!frameReady) { return false; @@ -596,7 +556,7 @@ private async Task InterpolateDragPosition( do { - bool frameReady = await WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); if (!frameReady) { return false; @@ -670,18 +630,18 @@ private async Task ExecuteDragStart( { SimulateMouseUiOverlayState.Update( MouseAction.DragStart, inputPos, null, null, Handles.GetMainGameViewSize()); - bool expandCompleted = await PlayExpandAnimation(ct).ConfigureAwait(false); + bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); if (!expandCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragStart, inputPos, null, null); } await MainThreadSwitcher.SwitchToMainThread(ct); - bool dissipateCompleted = await PlayDissipateAnimation(ct).ConfigureAwait(false); + bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); if (!dissipateCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragStart, inputPos, null, null); } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -712,10 +672,10 @@ private async Task ExecuteDragStart( bool animationCompleted = false; try { - animationCompleted = await PlayExpandAnimation(ct).ConfigureAwait(false); + animationCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false); if (!animationCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragStart, inputPos, null, targetName); } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -726,7 +686,7 @@ private async Task ExecuteDragStart( // Cancellation during animation leaves beginDrag dispatched; clean up if (!animationCompleted) { - ExecuteCleanupOnMainThread(() => + _mainThreadCleanupScheduler.ExecuteCleanupOnMainThread(() => { FinalizeDrag(pointerData, target, null); MouseDragState.Clear(); @@ -787,7 +747,7 @@ private async Task ExecuteDragMove( parameters.DragSpeed, ct).ConfigureAwait(false); if (!dragCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragMove, inputEnd, null, targetName); } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -859,22 +819,22 @@ private async Task ExecuteDragEnd( parameters.DragSpeed, ct).ConfigureAwait(false); if (!dragCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragEnd, inputEnd, null, targetName); } await MainThreadSwitcher.SwitchToMainThread(ct); - bool frameReady = await WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); + bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); if (!frameReady) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragEnd, inputEnd, null, targetName); } await MainThreadSwitcher.SwitchToMainThread(ct); } finally { - ExecuteCleanupOnMainThread(() => + _mainThreadCleanupScheduler.ExecuteCleanupOnMainThread(() => { FinalizeDrag(pointerData, target, explicitDropTarget); MouseDragState.Clear(); @@ -884,10 +844,10 @@ private async Task ExecuteDragEnd( SimulateMouseUiOverlayState.Update( MouseAction.DragEnd, inputEnd, null, targetName, Handles.GetMainGameViewSize()); - bool dissipateCompleted = await PlayDissipateAnimation(ct).ConfigureAwait(false); + bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false); if (!dissipateCompleted) { - QueueOverlayClear(); + _mainThreadCleanupScheduler.QueueOverlayClear(); return MouseUiSimulationResponseFactory.CreateDragEndResult(parameters, inputEnd, targetName); } await MainThreadSwitcher.SwitchToMainThread(ct); @@ -927,106 +887,5 @@ private async Task ExecuteDragEnd( return null; } - private static async Task PlayExpandAnimation(CancellationToken ct) - { - SimulateMouseUiOverlay overlay = OverlayCanvasFactory.VisualizationCanvas.MouseUiOverlay; - - // Previous dissipate sets alpha to 0; restore before expand starts - overlay.SetAlpha(1f); - - float startTime = Time.realtimeSinceStartup; - float elapsed = 0f; - while (elapsed < EXPAND_DURATION) - { - float t = elapsed / EXPAND_DURATION; - overlay.SetCursorScale(Mathf.Lerp(EXPAND_START_SCALE, 1f, t)); - bool frameReady = await WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); - if (!frameReady) - { - return false; - } - await MainThreadSwitcher.SwitchToMainThread(ct); - elapsed = Time.realtimeSinceStartup - startTime; - } - overlay.SetCursorScale(1f); - return true; - } - - private static async Task PlayDissipateAnimation(CancellationToken ct) - { - SimulateMouseUiOverlay overlay = OverlayCanvasFactory.VisualizationCanvas.MouseUiOverlay; - - float startTime = Time.realtimeSinceStartup; - float elapsed = 0f; - while (elapsed < DISSIPATE_DURATION) - { - float t = elapsed / DISSIPATE_DURATION; - overlay.SetCursorScale(Mathf.Lerp(1f, 0f, t)); - overlay.SetAlpha(Mathf.Lerp(1f, 0f, t)); - bool frameReady = await WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false); - if (!frameReady) - { - return false; - } - await MainThreadSwitcher.SwitchToMainThread(ct); - elapsed = Time.realtimeSinceStartup - startTime; - } - overlay!.SetCursorScale(0f); - overlay!.SetAlpha(0f); - SimulateMouseUiOverlayState.Clear(); - return true; - } - - private static async Task WaitForEditorFrameAndSwitchToMainThreadAsync(CancellationToken ct) - { - bool frameReady = await EditorFrameWaiter.WaitFramesOrTimeoutAsync( - 1, - UnityCliLoopConstants.EDITOR_FRAME_WAIT_TIMEOUT_MS, - ct).ConfigureAwait(false); - if (!frameReady) - { - return false; - } - - await MainThreadSwitcher.SwitchToMainThread(ct); - return true; - } - - private void CaptureMainThreadContext() - { - _mainThreadContext = SynchronizationContext.Current; - Debug.Assert(_mainThreadContext != null, "Main thread synchronization context must be captured."); - } - - private void QueueOverlayClear() - { - ExecuteCleanupOnMainThread(SimulateMouseUiOverlayState.Clear); - } - - private void ExecuteCleanupOnMainThread(Action cleanup) - { - Debug.Assert(cleanup != null, "cleanup must not be null"); - if (cleanup == null) - { - throw new ArgumentNullException(nameof(cleanup)); - } - - if (MainThreadSwitcher.IsMainThread) - { - cleanup(); - return; - } - - SynchronizationContext? context = _mainThreadContext; - Debug.Assert(context != null, "Main thread synchronization context must be captured before cleanup."); - if (context == null) - { - throw new InvalidOperationException("Main thread synchronization context was not captured."); - } - - // Why: timeout continuations can run on timer threads while Unity objects must still be cleaned up on the Editor thread. - context.Post(_ => cleanup(), null); - } - } }