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);
- }
-
}
}