diff --git a/Assets/Tests/Editor/MouseUiDragTargetResolverTests.cs b/Assets/Tests/Editor/MouseUiDragTargetResolverTests.cs
new file mode 100644
index 0000000000..104754fb03
--- /dev/null
+++ b/Assets/Tests/Editor/MouseUiDragTargetResolverTests.cs
@@ -0,0 +1,159 @@
+#nullable enable
+using System.Collections.Generic;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.EventSystems;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+using io.github.hatayama.UnityCliLoop.Runtime;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Verifies drag target resolution shared by one-shot and incremental mouse UI drags.
+ ///
+ [TestFixture]
+ public sealed class MouseUiDragTargetResolverTests
+ {
+ private readonly List createdObjects = new();
+
+ [TearDown]
+ public void TearDown()
+ {
+ for (int index = createdObjects.Count - 1; index >= 0; index--)
+ {
+ GameObject gameObject = createdObjects[index];
+ if (gameObject != null)
+ {
+ Object.DestroyImmediate(gameObject);
+ }
+ }
+
+ createdObjects.Clear();
+ }
+
+ ///
+ /// Verifies bypass resolution preserves the raw raycast target and resolves its hierarchy drag handler.
+ ///
+ [Test]
+ public void Resolve_WithBypassPathBelowDragHandler_ReturnsHandlerAndRawRaycast()
+ {
+ EventSystem eventSystem = CreateEventSystem();
+ GameObject expectedTarget = CreateGameObject("MouseUiDragTargetResolverTests_Handler");
+ expectedTarget.AddComponent();
+ GameObject rawTarget = CreateGameObject("RawTarget", expectedTarget.transform);
+ MouseUiSimulationCommand command = CreateCommand(
+ UnityCliLoopMouseUiAction.Drag,
+ GameObjectPathUtility.GetFullPath(rawTarget));
+ Vector2 position = new(10f, 20f);
+
+ (RaycastResult startRaycast, GameObject? target, SimulateMouseUiResponse? failureResponse) =
+ MouseUiDragTargetResolver.Resolve(
+ command,
+ eventSystem,
+ MouseAction.Drag,
+ position,
+ position);
+
+ Assert.That(startRaycast.gameObject, Is.SameAs(rawTarget));
+ Assert.That(target, Is.SameAs(expectedTarget));
+ Assert.That(failureResponse, Is.Null);
+ }
+
+ ///
+ /// Verifies bypass resolution keeps the raw raycast while returning no target when no drag handler exists.
+ ///
+ [Test]
+ public void Resolve_WithBypassPathWithoutDragHandler_ReturnsRawRaycastWithoutFailure()
+ {
+ EventSystem eventSystem = CreateEventSystem();
+ GameObject rawTarget = CreateGameObject("MouseUiDragTargetResolverTests_NoHandler");
+ MouseUiSimulationCommand command = CreateCommand(
+ UnityCliLoopMouseUiAction.DragStart,
+ GameObjectPathUtility.GetFullPath(rawTarget));
+ Vector2 position = new(30f, 40f);
+
+ (RaycastResult startRaycast, GameObject? target, SimulateMouseUiResponse? failureResponse) =
+ MouseUiDragTargetResolver.Resolve(
+ command,
+ eventSystem,
+ MouseAction.DragStart,
+ position,
+ position);
+
+ Assert.That(startRaycast.gameObject, Is.SameAs(rawTarget));
+ Assert.That(target, Is.Null);
+ Assert.That(failureResponse, Is.Null);
+ }
+
+ ///
+ /// Verifies a missing bypass path preserves the requested action and input position in the failure response.
+ ///
+ [Test]
+ public void Resolve_WithMissingBypassPath_ReturnsPathFailure()
+ {
+ EventSystem eventSystem = CreateEventSystem();
+ string targetPath = "MouseUiDragTargetResolverTests_Missing/Target";
+ MouseUiSimulationCommand command = CreateCommand(
+ UnityCliLoopMouseUiAction.DragStart,
+ targetPath);
+ Vector2 position = new(50f, 60f);
+
+ (RaycastResult startRaycast, GameObject? target, SimulateMouseUiResponse? failureResponse) =
+ MouseUiDragTargetResolver.Resolve(
+ command,
+ eventSystem,
+ MouseAction.DragStart,
+ position,
+ position);
+
+ Assert.That(startRaycast.gameObject, Is.Null);
+ Assert.That(target, Is.Null);
+ Assert.That(failureResponse, Is.Not.Null);
+ Assert.That(failureResponse!.Message, Is.EqualTo($"TargetPath '{targetPath}' was not found."));
+ Assert.That(failureResponse.Action, Is.EqualTo(MouseAction.DragStart.ToString()));
+ Assert.That(failureResponse.PositionX, Is.EqualTo(position.x));
+ Assert.That(failureResponse.PositionY, Is.EqualTo(position.y));
+ }
+
+ private EventSystem CreateEventSystem()
+ {
+ return CreateGameObject("MouseUiDragTargetResolverTests_EventSystem").AddComponent();
+ }
+
+ private GameObject CreateGameObject(string name, Transform? parent = null)
+ {
+ GameObject gameObject = new(name);
+ createdObjects.Add(gameObject);
+ if (parent != null)
+ {
+ gameObject.transform.SetParent(parent);
+ }
+
+ return gameObject;
+ }
+
+ private static MouseUiSimulationCommand CreateCommand(
+ UnityCliLoopMouseUiAction action,
+ string targetPath)
+ {
+ (MouseUiSimulationCommand? command, string? errorMessage) =
+ MouseUiSimulationCommand.TryFromSchema(new SimulateMouseUiSchema
+ {
+ Action = action,
+ BypassRaycast = true,
+ TargetPath = targetPath
+ });
+ Assert.That(errorMessage, Is.Null);
+ Assert.That(command, Is.Not.Null);
+ return command!;
+ }
+ }
+
+ internal sealed class MouseUiDragTargetResolverTestHandler : MonoBehaviour, IDragHandler
+ {
+ public void OnDrag(PointerEventData eventData)
+ {
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/MouseUiDragTargetResolverTests.cs.meta b/Assets/Tests/Editor/MouseUiDragTargetResolverTests.cs.meta
new file mode 100644
index 0000000000..8d1a41df8c
--- /dev/null
+++ b/Assets/Tests/Editor/MouseUiDragTargetResolverTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 2fc73e4077664e5daf22948c88e16e6a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragTargetResolver.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragTargetResolver.cs
new file mode 100644
index 0000000000..e2c96a7140
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragTargetResolver.cs
@@ -0,0 +1,59 @@
+#nullable enable
+using UnityEngine;
+using UnityEngine.EventSystems;
+
+using io.github.hatayama.UnityCliLoop.Runtime;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Resolves the draggable target and starting raycast for mouse UI drag actions.
+ ///
+ internal static class MouseUiDragTargetResolver
+ {
+ internal static (
+ RaycastResult StartRaycast,
+ GameObject? Target,
+ SimulateMouseUiResponse? FailureResponse) Resolve(
+ MouseUiSimulationCommand parameters,
+ EventSystem eventSystem,
+ MouseAction action,
+ Vector2 inputPosition,
+ Vector2 screenPosition)
+ {
+ RaycastResult? hit = parameters.BypassRaycast
+ ? null
+ : UiRaycastHelper.RaycastUI(screenPosition, eventSystem);
+ RaycastResult startRaycast = new();
+ GameObject? rawTarget = null;
+
+ if (parameters.BypassRaycast)
+ {
+ if (!MouseUiPointerTargetResolver.TryResolveGameObjectPath(
+ parameters.TargetPath,
+ "TargetPath",
+ action,
+ inputPosition,
+ out rawTarget,
+ out SimulateMouseUiResponse? failureResponse))
+ {
+ return (startRaycast, null, failureResponse);
+ }
+
+ startRaycast = MouseUiPointerTargetResolver.CreateDirectRaycastResult(rawTarget!);
+ }
+ else if (hit != null)
+ {
+ rawTarget = hit.Value.gameObject;
+ startRaycast = hit.Value;
+ }
+
+ // Execute dispatches only to the exact target; resolve the actual drag handler up the hierarchy.
+ GameObject? target = rawTarget != null
+ ? ExecuteEvents.GetEventHandler(rawTarget)
+ : null;
+
+ return (startRaycast, target, null);
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragTargetResolver.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragTargetResolver.cs.meta
new file mode 100644
index 0000000000..fe49a7f6c7
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiDragTargetResolver.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 75e51941d15f45ed855f7fffab6432ab
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiIncrementalDragExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiIncrementalDragExecutor.cs
new file mode 100644
index 0000000000..d12c0eb9ac
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiIncrementalDragExecutor.cs
@@ -0,0 +1,315 @@
+#nullable enable
+using System.Threading;
+using System.Threading.Tasks;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.EventSystems;
+
+using io.github.hatayama.UnityCliLoop.Runtime;
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Executes incremental mouse UI drag start, move, and end requests.
+ ///
+ internal static class MouseUiIncrementalDragExecutor
+ {
+ internal static async Task ExecuteDragStart(
+ MouseUiSimulationCommand parameters,
+ EventSystem eventSystem,
+ MouseUiMainThreadCleanupScheduler cleanupScheduler,
+ CancellationToken ct)
+ {
+ if (MouseDragState.IsDragging)
+ {
+ return new SimulateMouseUiResponse
+ {
+ Success = false,
+ Message = "A drag is already in progress. Call DragEnd first.",
+ Action = MouseAction.DragStart.ToString(),
+ PositionX = parameters.X,
+ PositionY = parameters.Y
+ };
+ }
+
+ Vector2 inputPos = new(parameters.X, parameters.Y);
+ Vector2 screenPos = MouseUiCoordinateConverter.InputToScreen(inputPos);
+ (RaycastResult startRaycast, GameObject? target, SimulateMouseUiResponse? targetFailureResponse) =
+ MouseUiDragTargetResolver.Resolve(
+ parameters,
+ eventSystem,
+ MouseAction.DragStart,
+ inputPos,
+ screenPos);
+ if (targetFailureResponse != null)
+ {
+ return targetFailureResponse;
+ }
+
+ if (target == null)
+ {
+ SimulateMouseUiOverlayState.Update(
+ MouseAction.DragStart, inputPos, null, null, Handles.GetMainGameViewSize());
+ bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false);
+ if (!expandCompleted)
+ {
+ cleanupScheduler.QueueOverlayClear();
+ return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragStart, inputPos, null, null);
+ }
+ await MainThreadSwitcher.SwitchToMainThread(ct);
+
+ bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false);
+ if (!dissipateCompleted)
+ {
+ cleanupScheduler.QueueOverlayClear();
+ return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragStart, inputPos, null, null);
+ }
+ await MainThreadSwitcher.SwitchToMainThread(ct);
+
+ return new SimulateMouseUiResponse
+ {
+ Success = false,
+ Message = parameters.BypassRaycast
+ ? $"TargetPath '{parameters.TargetPath}' has no drag handler."
+ : $"No draggable UI element at ({inputPos.x:F1}, {inputPos.y:F1}). Use find-game-objects or screenshot to verify positions.",
+ Action = MouseAction.DragStart.ToString(),
+ PositionX = inputPos.x,
+ PositionY = inputPos.y
+ };
+ }
+
+ PointerEventData pointerData = MouseUiDragEventExecutor.InitiateDrag(eventSystem, screenPos, startRaycast, target, PointerEventData.InputButton.Left);
+ ExecuteEvents.Execute(target, pointerData, ExecuteEvents.beginDragHandler);
+ pointerData.dragging = true;
+
+ MouseDragState.Target = target;
+ MouseDragState.PointerData = pointerData;
+
+ string targetName = target.name;
+ SimulateMouseUiOverlayState.Update(
+ MouseAction.DragStart, inputPos, inputPos, targetName, Handles.GetMainGameViewSize());
+
+ bool animationCompleted = false;
+ try
+ {
+ animationCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false);
+ if (!animationCompleted)
+ {
+ cleanupScheduler.QueueOverlayClear();
+ return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragStart, inputPos, null, targetName);
+ }
+ await MainThreadSwitcher.SwitchToMainThread(ct);
+ animationCompleted = true;
+ }
+ finally
+ {
+ // Cancellation during animation leaves beginDrag dispatched; clean up
+ if (!animationCompleted)
+ {
+ cleanupScheduler.ExecuteCleanupOnMainThread(() =>
+ {
+ MouseUiDragEventExecutor.FinalizeDrag(pointerData, target, null);
+ MouseDragState.Clear();
+ });
+ }
+ }
+
+ return new SimulateMouseUiResponse
+ {
+ Success = true,
+ Message = $"Drag started on '{targetName}' at ({inputPos.x:F1}, {inputPos.y:F1})",
+ Action = MouseAction.DragStart.ToString(),
+ HitGameObjectName = targetName,
+ PositionX = inputPos.x,
+ PositionY = inputPos.y
+ };
+ }
+
+ internal static async Task ExecuteDragMove(
+ MouseUiSimulationCommand parameters,
+ MouseUiMainThreadCleanupScheduler cleanupScheduler,
+ CancellationToken ct)
+ {
+ if (!MouseDragState.IsDragging)
+ {
+ return new SimulateMouseUiResponse
+ {
+ Success = false,
+ Message = "No drag in progress. Call DragStart first.",
+ Action = MouseAction.DragMove.ToString(),
+ PositionX = parameters.X,
+ PositionY = parameters.Y
+ };
+ }
+
+ Debug.Assert(MouseDragState.Target != null, "Target must not be null when IsDragging is true");
+ Debug.Assert(MouseDragState.PointerData != null, "PointerData must not be null when IsDragging is true");
+
+ SimulateMouseUiResponse? invalidResponse = ValidateDragStillActive(parameters.Action);
+ if (invalidResponse != null)
+ {
+ return invalidResponse;
+ }
+
+ Vector2 inputEnd = new(parameters.X, parameters.Y);
+ Vector2 screenEnd = MouseUiCoordinateConverter.InputToScreen(inputEnd);
+ PointerEventData pointerData = MouseDragState.PointerData!;
+ GameObject target = MouseDragState.Target!;
+ string targetName = target.name;
+
+ SimulateMouseUiOverlayState.Update(
+ MouseAction.DragMove,
+ MouseUiCoordinateConverter.ScreenToInput(pointerData.position),
+ SimulateMouseUiOverlayState.DragStartPosition,
+ targetName, Handles.GetMainGameViewSize());
+
+ // Cancellation leaves drag state intact so the user can continue with DragMove/DragEnd
+ bool dragCompleted = await MouseUiDragEventExecutor.InterpolateDragPosition(
+ pointerData, target, screenEnd,
+ parameters.DragSpeed, ct).ConfigureAwait(false);
+ if (!dragCompleted)
+ {
+ cleanupScheduler.QueueOverlayClear();
+ return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragMove, inputEnd, null, targetName);
+ }
+ await MainThreadSwitcher.SwitchToMainThread(ct);
+
+ SimulateMouseUiOverlayState.AddWaypoint(inputEnd);
+
+ return new SimulateMouseUiResponse
+ {
+ Success = true,
+ Message = $"Drag moved on '{targetName}' to ({inputEnd.x:F1}, {inputEnd.y:F1}) at {parameters.DragSpeed:F0} px/s",
+ Action = MouseAction.DragMove.ToString(),
+ HitGameObjectName = targetName,
+ PositionX = inputEnd.x,
+ PositionY = inputEnd.y
+ };
+ }
+
+ internal static async Task ExecuteDragEnd(
+ MouseUiSimulationCommand parameters,
+ MouseUiMainThreadCleanupScheduler cleanupScheduler,
+ CancellationToken ct)
+ {
+ if (!MouseDragState.IsDragging)
+ {
+ return new SimulateMouseUiResponse
+ {
+ Success = false,
+ Message = "No drag in progress. Call DragStart first.",
+ Action = MouseAction.DragEnd.ToString(),
+ PositionX = parameters.X,
+ PositionY = parameters.Y
+ };
+ }
+
+ Debug.Assert(MouseDragState.Target != null, "Target must not be null when IsDragging is true");
+ Debug.Assert(MouseDragState.PointerData != null, "PointerData must not be null when IsDragging is true");
+
+ SimulateMouseUiResponse? invalidResponse = ValidateDragStillActive(parameters.Action);
+ if (invalidResponse != null)
+ {
+ return invalidResponse;
+ }
+
+ Vector2 inputEnd = new(parameters.X, parameters.Y);
+ Vector2 screenEnd = MouseUiCoordinateConverter.InputToScreen(inputEnd);
+ PointerEventData pointerData = MouseDragState.PointerData!;
+ GameObject target = MouseDragState.Target!;
+ string targetName = target.name;
+ GameObject? explicitDropTarget = null;
+
+ if (!MouseUiPointerTargetResolver.TryResolveDropTargetPath(
+ parameters,
+ MouseAction.DragEnd,
+ inputEnd,
+ out explicitDropTarget,
+ out SimulateMouseUiResponse? dropFailureResponse))
+ {
+ return dropFailureResponse!;
+ }
+
+ SimulateMouseUiOverlayState.Update(
+ MouseAction.DragEnd,
+ MouseUiCoordinateConverter.ScreenToInput(pointerData.position),
+ SimulateMouseUiOverlayState.DragStartPosition,
+ targetName, Handles.GetMainGameViewSize());
+
+ try
+ {
+ bool dragCompleted = await MouseUiDragEventExecutor.InterpolateDragPosition(
+ pointerData, target, screenEnd,
+ parameters.DragSpeed, ct).ConfigureAwait(false);
+ if (!dragCompleted)
+ {
+ cleanupScheduler.QueueOverlayClear();
+ return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragEnd, inputEnd, null, targetName);
+ }
+ await MainThreadSwitcher.SwitchToMainThread(ct);
+
+ bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false);
+ if (!frameReady)
+ {
+ cleanupScheduler.QueueOverlayClear();
+ return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragEnd, inputEnd, null, targetName);
+ }
+ await MainThreadSwitcher.SwitchToMainThread(ct);
+ }
+ finally
+ {
+ cleanupScheduler.ExecuteCleanupOnMainThread(() =>
+ {
+ MouseUiDragEventExecutor.FinalizeDrag(pointerData, target, explicitDropTarget);
+ MouseDragState.Clear();
+ });
+ }
+
+ SimulateMouseUiOverlayState.Update(
+ MouseAction.DragEnd, inputEnd, null, targetName, Handles.GetMainGameViewSize());
+
+ bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false);
+ if (!dissipateCompleted)
+ {
+ cleanupScheduler.QueueOverlayClear();
+ return MouseUiSimulationResponseFactory.CreateDragEndResult(parameters, inputEnd, targetName);
+ }
+ await MainThreadSwitcher.SwitchToMainThread(ct);
+
+ return MouseUiSimulationResponseFactory.CreateDragEndResult(parameters, inputEnd, targetName);
+ }
+
+ // User input during a CLI drag can cause Unity's StandaloneInputModule to
+ // release or reassign the drag, leaving MouseDragState stale.
+ private static SimulateMouseUiResponse? ValidateDragStillActive(MouseAction action)
+ {
+ if (!MouseDragState.Target!.activeInHierarchy)
+ {
+ MouseDragState.Clear();
+ SimulateMouseUiOverlayState.Clear();
+ return new SimulateMouseUiResponse
+ {
+ Success = false,
+ Message = "Drag target was destroyed or deactivated during drag.",
+ Action = action.ToString()
+ };
+ }
+
+ if (!MouseDragState.PointerData!.dragging ||
+ MouseDragState.PointerData.pointerDrag != MouseDragState.Target)
+ {
+ MouseDragState.Clear();
+ SimulateMouseUiOverlayState.Clear();
+ return new SimulateMouseUiResponse
+ {
+ Success = false,
+ Message = "Drag was interrupted by user input or system event.",
+ Action = action.ToString()
+ };
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiIncrementalDragExecutor.cs.meta b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiIncrementalDragExecutor.cs.meta
new file mode 100644
index 0000000000..a7708721a8
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiIncrementalDragExecutor.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 743456673dfa4c24a068f861bdda83a2
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOneShotDragExecutor.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOneShotDragExecutor.cs
index 4b5b25e43a..43e84c8ce9 100644
--- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOneShotDragExecutor.cs
+++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/MouseUiOneShotDragExecutor.cs
@@ -25,29 +25,16 @@ internal static async Task ExecuteDragOneShot(
Vector2 inputEnd = new(parameters.X, parameters.Y);
Vector2 screenStart = MouseUiCoordinateConverter.InputToScreen(inputStart);
Vector2 screenEnd = MouseUiCoordinateConverter.InputToScreen(inputEnd);
- RaycastResult? hit = parameters.BypassRaycast ? null : UiRaycastHelper.RaycastUI(screenStart, eventSystem);
- RaycastResult startRaycast = new();
- GameObject? rawTarget = null;
-
- if (parameters.BypassRaycast)
- {
- if (!MouseUiPointerTargetResolver.TryResolveGameObjectPath(
- parameters.TargetPath,
- "TargetPath",
+ (RaycastResult startRaycast, GameObject? target, SimulateMouseUiResponse? targetFailureResponse) =
+ MouseUiDragTargetResolver.Resolve(
+ parameters,
+ eventSystem,
MouseAction.Drag,
inputStart,
- out rawTarget,
- out SimulateMouseUiResponse? failureResponse))
- {
- return failureResponse!;
- }
-
- startRaycast = MouseUiPointerTargetResolver.CreateDirectRaycastResult(rawTarget!);
- }
- else if (hit != null)
+ screenStart);
+ if (targetFailureResponse != null)
{
- rawTarget = hit.Value.gameObject;
- startRaycast = hit.Value;
+ return targetFailureResponse;
}
GameObject? explicitDropTarget = null;
@@ -61,11 +48,6 @@ internal static async Task ExecuteDragOneShot(
return dropFailureResponse!;
}
- // Execute dispatches only to the exact target; resolve the actual drag handler up the hierarchy
- GameObject? target = rawTarget != null
- ? ExecuteEvents.GetEventHandler(rawTarget)
- : null;
-
if (target == null)
{
SimulateMouseUiOverlayState.Update(
diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs
index 8bf22ca3aa..1a711654fe 100644
--- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs
+++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs
@@ -1,7 +1,6 @@
#nullable enable
using System.Threading;
using System.Threading.Tasks;
-using UnityEditor;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
@@ -99,13 +98,23 @@ private async Task ExecuteMouseAction(
ct).ConfigureAwait(false);
case MouseAction.DragStart:
- return await ExecuteDragStart(parameters, eventSystem, ct).ConfigureAwait(false);
+ return await MouseUiIncrementalDragExecutor.ExecuteDragStart(
+ parameters,
+ eventSystem,
+ _mainThreadCleanupScheduler,
+ ct).ConfigureAwait(false);
case MouseAction.DragMove:
- return await ExecuteDragMove(parameters, ct).ConfigureAwait(false);
+ return await MouseUiIncrementalDragExecutor.ExecuteDragMove(
+ parameters,
+ _mainThreadCleanupScheduler,
+ ct).ConfigureAwait(false);
case MouseAction.DragEnd:
- return await ExecuteDragEnd(parameters, ct).ConfigureAwait(false);
+ return await MouseUiIncrementalDragExecutor.ExecuteDragEnd(
+ parameters,
+ _mainThreadCleanupScheduler,
+ ct).ConfigureAwait(false);
case MouseAction.LongPress:
return await MouseUiPressActionExecutor.ExecuteLongPress(
@@ -123,312 +132,5 @@ private async Task ExecuteMouseAction(
}
}
- private async Task ExecuteDragStart(
- MouseUiSimulationCommand parameters, EventSystem eventSystem, CancellationToken ct)
- {
- if (MouseDragState.IsDragging)
- {
- return new SimulateMouseUiResponse
- {
- Success = false,
- Message = "A drag is already in progress. Call DragEnd first.",
- Action = MouseAction.DragStart.ToString(),
- PositionX = parameters.X,
- PositionY = parameters.Y
- };
- }
-
- Vector2 inputPos = new(parameters.X, parameters.Y);
- Vector2 screenPos = MouseUiCoordinateConverter.InputToScreen(inputPos);
- RaycastResult? hit = parameters.BypassRaycast ? null : UiRaycastHelper.RaycastUI(screenPos, eventSystem);
- RaycastResult startRaycast = new();
- GameObject? rawTarget = null;
-
- if (parameters.BypassRaycast)
- {
- if (!MouseUiPointerTargetResolver.TryResolveGameObjectPath(
- parameters.TargetPath,
- "TargetPath",
- MouseAction.DragStart,
- inputPos,
- out rawTarget,
- out SimulateMouseUiResponse? failureResponse))
- {
- return failureResponse!;
- }
-
- startRaycast = MouseUiPointerTargetResolver.CreateDirectRaycastResult(rawTarget!);
- }
- else if (hit != null)
- {
- rawTarget = hit.Value.gameObject;
- startRaycast = hit.Value;
- }
-
- GameObject? target = rawTarget != null
- ? ExecuteEvents.GetEventHandler(rawTarget)
- : null;
-
- if (target == null)
- {
- SimulateMouseUiOverlayState.Update(
- MouseAction.DragStart, inputPos, null, null, Handles.GetMainGameViewSize());
- bool expandCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false);
- if (!expandCompleted)
- {
- _mainThreadCleanupScheduler.QueueOverlayClear();
- return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragStart, inputPos, null, null);
- }
- await MainThreadSwitcher.SwitchToMainThread(ct);
-
- bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false);
- if (!dissipateCompleted)
- {
- _mainThreadCleanupScheduler.QueueOverlayClear();
- return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragStart, inputPos, null, null);
- }
- await MainThreadSwitcher.SwitchToMainThread(ct);
-
- return new SimulateMouseUiResponse
- {
- Success = false,
- Message = parameters.BypassRaycast
- ? $"TargetPath '{parameters.TargetPath}' has no drag handler."
- : $"No draggable UI element at ({inputPos.x:F1}, {inputPos.y:F1}). Use find-game-objects or screenshot to verify positions.",
- Action = MouseAction.DragStart.ToString(),
- PositionX = inputPos.x,
- PositionY = inputPos.y
- };
- }
-
- PointerEventData pointerData = MouseUiDragEventExecutor.InitiateDrag(eventSystem, screenPos, startRaycast, target, PointerEventData.InputButton.Left);
- ExecuteEvents.Execute(target, pointerData, ExecuteEvents.beginDragHandler);
- pointerData.dragging = true;
-
- MouseDragState.Target = target;
- MouseDragState.PointerData = pointerData;
-
- string targetName = target.name;
- SimulateMouseUiOverlayState.Update(
- MouseAction.DragStart, inputPos, inputPos, targetName, Handles.GetMainGameViewSize());
-
- bool animationCompleted = false;
- try
- {
- animationCompleted = await MouseUiOverlayAnimator.PlayExpandAnimation(ct).ConfigureAwait(false);
- if (!animationCompleted)
- {
- _mainThreadCleanupScheduler.QueueOverlayClear();
- return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragStart, inputPos, null, targetName);
- }
- await MainThreadSwitcher.SwitchToMainThread(ct);
- animationCompleted = true;
- }
- finally
- {
- // Cancellation during animation leaves beginDrag dispatched; clean up
- if (!animationCompleted)
- {
- _mainThreadCleanupScheduler.ExecuteCleanupOnMainThread(() =>
- {
- MouseUiDragEventExecutor.FinalizeDrag(pointerData, target, null);
- MouseDragState.Clear();
- });
- }
- }
-
- return new SimulateMouseUiResponse
- {
- Success = true,
- Message = $"Drag started on '{targetName}' at ({inputPos.x:F1}, {inputPos.y:F1})",
- Action = MouseAction.DragStart.ToString(),
- HitGameObjectName = targetName,
- PositionX = inputPos.x,
- PositionY = inputPos.y
- };
- }
-
- private async Task ExecuteDragMove(
- MouseUiSimulationCommand parameters, CancellationToken ct)
- {
- if (!MouseDragState.IsDragging)
- {
- return new SimulateMouseUiResponse
- {
- Success = false,
- Message = "No drag in progress. Call DragStart first.",
- Action = MouseAction.DragMove.ToString(),
- PositionX = parameters.X,
- PositionY = parameters.Y
- };
- }
-
- Debug.Assert(MouseDragState.Target != null, "Target must not be null when IsDragging is true");
- Debug.Assert(MouseDragState.PointerData != null, "PointerData must not be null when IsDragging is true");
-
- SimulateMouseUiResponse? invalidResponse = ValidateDragStillActive(parameters.Action);
- if (invalidResponse != null)
- {
- return invalidResponse;
- }
-
- Vector2 inputEnd = new(parameters.X, parameters.Y);
- Vector2 screenEnd = MouseUiCoordinateConverter.InputToScreen(inputEnd);
- PointerEventData pointerData = MouseDragState.PointerData!;
- GameObject target = MouseDragState.Target!;
- string targetName = target.name;
-
- SimulateMouseUiOverlayState.Update(
- MouseAction.DragMove,
- MouseUiCoordinateConverter.ScreenToInput(pointerData.position),
- SimulateMouseUiOverlayState.DragStartPosition,
- targetName, Handles.GetMainGameViewSize());
-
- // Cancellation leaves drag state intact so the user can continue with DragMove/DragEnd
- bool dragCompleted = await MouseUiDragEventExecutor.InterpolateDragPosition(
- pointerData, target, screenEnd,
- parameters.DragSpeed, ct).ConfigureAwait(false);
- if (!dragCompleted)
- {
- _mainThreadCleanupScheduler.QueueOverlayClear();
- return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragMove, inputEnd, null, targetName);
- }
- await MainThreadSwitcher.SwitchToMainThread(ct);
-
- SimulateMouseUiOverlayState.AddWaypoint(inputEnd);
-
- return new SimulateMouseUiResponse
- {
- Success = true,
- Message = $"Drag moved on '{targetName}' to ({inputEnd.x:F1}, {inputEnd.y:F1}) at {parameters.DragSpeed:F0} px/s",
- Action = MouseAction.DragMove.ToString(),
- HitGameObjectName = targetName,
- PositionX = inputEnd.x,
- PositionY = inputEnd.y
- };
- }
-
- private async Task ExecuteDragEnd(
- MouseUiSimulationCommand parameters, CancellationToken ct)
- {
- if (!MouseDragState.IsDragging)
- {
- return new SimulateMouseUiResponse
- {
- Success = false,
- Message = "No drag in progress. Call DragStart first.",
- Action = MouseAction.DragEnd.ToString(),
- PositionX = parameters.X,
- PositionY = parameters.Y
- };
- }
-
- Debug.Assert(MouseDragState.Target != null, "Target must not be null when IsDragging is true");
- Debug.Assert(MouseDragState.PointerData != null, "PointerData must not be null when IsDragging is true");
-
- SimulateMouseUiResponse? invalidResponse = ValidateDragStillActive(parameters.Action);
- if (invalidResponse != null)
- {
- return invalidResponse;
- }
-
- Vector2 inputEnd = new(parameters.X, parameters.Y);
- Vector2 screenEnd = MouseUiCoordinateConverter.InputToScreen(inputEnd);
- PointerEventData pointerData = MouseDragState.PointerData!;
- GameObject target = MouseDragState.Target!;
- string targetName = target.name;
- GameObject? explicitDropTarget = null;
-
- if (!MouseUiPointerTargetResolver.TryResolveDropTargetPath(
- parameters,
- MouseAction.DragEnd,
- inputEnd,
- out explicitDropTarget,
- out SimulateMouseUiResponse? dropFailureResponse))
- {
- return dropFailureResponse!;
- }
-
- SimulateMouseUiOverlayState.Update(
- MouseAction.DragEnd,
- MouseUiCoordinateConverter.ScreenToInput(pointerData.position),
- SimulateMouseUiOverlayState.DragStartPosition,
- targetName, Handles.GetMainGameViewSize());
-
- try
- {
- bool dragCompleted = await MouseUiDragEventExecutor.InterpolateDragPosition(
- pointerData, target, screenEnd,
- parameters.DragSpeed, ct).ConfigureAwait(false);
- if (!dragCompleted)
- {
- _mainThreadCleanupScheduler.QueueOverlayClear();
- return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragEnd, inputEnd, null, targetName);
- }
- await MainThreadSwitcher.SwitchToMainThread(ct);
-
- bool frameReady = await MouseUiEditorFrameWaiter.WaitForEditorFrameAndSwitchToMainThreadAsync(ct).ConfigureAwait(false);
- if (!frameReady)
- {
- _mainThreadCleanupScheduler.QueueOverlayClear();
- return MouseUiSimulationResponseFactory.CreateFrameTimeoutResult(MouseAction.DragEnd, inputEnd, null, targetName);
- }
- await MainThreadSwitcher.SwitchToMainThread(ct);
- }
- finally
- {
- _mainThreadCleanupScheduler.ExecuteCleanupOnMainThread(() =>
- {
- MouseUiDragEventExecutor.FinalizeDrag(pointerData, target, explicitDropTarget);
- MouseDragState.Clear();
- });
- }
-
- SimulateMouseUiOverlayState.Update(
- MouseAction.DragEnd, inputEnd, null, targetName, Handles.GetMainGameViewSize());
-
- bool dissipateCompleted = await MouseUiOverlayAnimator.PlayDissipateAnimation(ct).ConfigureAwait(false);
- if (!dissipateCompleted)
- {
- _mainThreadCleanupScheduler.QueueOverlayClear();
- return MouseUiSimulationResponseFactory.CreateDragEndResult(parameters, inputEnd, targetName);
- }
- await MainThreadSwitcher.SwitchToMainThread(ct);
-
- return MouseUiSimulationResponseFactory.CreateDragEndResult(parameters, inputEnd, targetName);
- }
-
- // User input during a CLI drag can cause Unity's StandaloneInputModule to
- // release or reassign the drag, leaving MouseDragState stale.
- private SimulateMouseUiResponse? ValidateDragStillActive(MouseAction action)
- {
- if (!MouseDragState.Target!.activeInHierarchy)
- {
- MouseDragState.Clear();
- SimulateMouseUiOverlayState.Clear();
- return new SimulateMouseUiResponse
- {
- Success = false,
- Message = "Drag target was destroyed or deactivated during drag.",
- Action = action.ToString()
- };
- }
-
- if (!MouseDragState.PointerData!.dragging ||
- MouseDragState.PointerData.pointerDrag != MouseDragState.Target)
- {
- MouseDragState.Clear();
- SimulateMouseUiOverlayState.Clear();
- return new SimulateMouseUiResponse
- {
- Success = false,
- Message = "Drag was interrupted by user input or system event.",
- Action = action.ToString()
- };
- }
-
- return null;
- }
-
}
}