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