From 26080353a175e817df830449dcc6991a63f36b95 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Tue, 16 Dec 2025 01:18:14 +0900 Subject: [PATCH] [Avalonia.Native] Use relative placement for detecting drag and drop locations. (#20280) * Use relative position of view for drag and drop * Add Drag And Drop Test * Add AutomationProperties --- native/Avalonia.Native/src/OSX/AvnView.mm | 7 +- .../IntegrationTestApp/MainWindow.axaml.cs | 1 + .../Pages/DragDropPage.axaml | 62 ++++++++++ .../Pages/DragDropPage.axaml.cs | 103 +++++++++++++++ .../DragDropTests.cs | 117 ++++++++++++++++++ 5 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 samples/IntegrationTestApp/Pages/DragDropPage.axaml create mode 100644 samples/IntegrationTestApp/Pages/DragDropPage.axaml.cs create mode 100644 tests/Avalonia.IntegrationTests.Appium/DragDropTests.cs diff --git a/native/Avalonia.Native/src/OSX/AvnView.mm b/native/Avalonia.Native/src/OSX/AvnView.mm index d4942afced..0da6f43bf4 100644 --- a/native/Avalonia.Native/src/OSX/AvnView.mm +++ b/native/Avalonia.Native/src/OSX/AvnView.mm @@ -854,9 +854,10 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt) - (NSDragOperation)triggerAvnDragEvent: (AvnDragEventType) type info: (id )info { - auto localPoint = [self convertPoint:[info draggingLocation] toView:self]; - auto avnPoint = ToAvnPoint(localPoint); - auto point = [self translateLocalPoint:avnPoint]; + NSPoint eventLocation = [info draggingLocation]; + auto viewLocation = [self convertPoint:NSMakePoint(0, 0) toView:nil]; + auto localPoint = NSMakePoint(eventLocation.x - viewLocation.x, viewLocation.y - eventLocation.y); + auto point = ToAvnPoint(localPoint); auto modifiers = [self getModifiers:[[NSApp currentEvent] modifierFlags]]; NSDragOperation nsop = [info draggingSourceOperationMask]; diff --git a/samples/IntegrationTestApp/MainWindow.axaml.cs b/samples/IntegrationTestApp/MainWindow.axaml.cs index ee7c81bf22..be552201c0 100644 --- a/samples/IntegrationTestApp/MainWindow.axaml.cs +++ b/samples/IntegrationTestApp/MainWindow.axaml.cs @@ -82,6 +82,7 @@ namespace IntegrationTestApp new("ComboBox", () => new ComboBoxPage()), new("ContextMenu", () => new ContextMenuPage()), new("DesktopPage", () => new DesktopPage()), + new("DragDrop", () => new DragDropPage()), new("Embedding", () => new EmbeddingPage()), new("Gestures", () => new GesturesPage()), new("ListBox", () => new ListBoxPage()), diff --git a/samples/IntegrationTestApp/Pages/DragDropPage.axaml b/samples/IntegrationTestApp/Pages/DragDropPage.axaml new file mode 100644 index 0000000000..64221759be --- /dev/null +++ b/samples/IntegrationTestApp/Pages/DragDropPage.axaml @@ -0,0 +1,62 @@ + + + + + Drop Position: + + Status: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/IntegrationTestApp/Pages/DragDropPage.axaml.cs b/samples/IntegrationTestApp/Pages/DragDropPage.axaml.cs new file mode 100644 index 0000000000..ba5ad0a3b5 --- /dev/null +++ b/samples/IntegrationTestApp/Pages/DragDropPage.axaml.cs @@ -0,0 +1,103 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; + +namespace IntegrationTestApp.Pages; + +public partial class DragDropPage : UserControl +{ + public DragDropPage() + { + InitializeComponent(); + + // Set up drag-drop event handlers + AddHandler(DragDrop.DragOverEvent, DropTarget_DragOver); + AddHandler(DragDrop.DropEvent, DropTarget_Drop); + } + + private async void DragSource_PointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + var dragData = new DataTransfer(); + dragData.Add(DataTransferItem.CreateText("TestDragData")); + + DragDropStatus.Text = "Dragging..."; + + var result = await DragDrop.DoDragDropAsync(e, dragData, DragDropEffects.Copy | DragDropEffects.Move); + + DragDropStatus.Text = result switch + { + DragDropEffects.Copy => "Copied", + DragDropEffects.Move => "Moved", + DragDropEffects.None => "Cancelled", + _ => $"Result: {result}" + }; + } + } + + private void DropTarget_DragOver(object? sender, DragEventArgs e) + { + // Only handle events for the drop target + if (e.Source != DropTarget && !IsChildOf(e.Source as Visual, DropTarget)) + return; + + e.DragEffects = DragDropEffects.Copy; + + // Get the position relative to the drop target + var position = e.GetPosition(DropTarget); + DropPosition.Text = $"DragOver: ({position.X:F0}, {position.Y:F0})"; + } + + private void DropTarget_Drop(object? sender, DragEventArgs e) + { + // Only handle events for the drop target + if (e.Source != DropTarget && !IsChildOf(e.Source as Visual, DropTarget)) + return; + + // Get the position relative to the drop target + var position = e.GetPosition(DropTarget); + DropPosition.Text = $"Drop: ({position.X:F0}, {position.Y:F0})"; + + // Check if the position is within reasonable bounds of the drop target + var bounds = DropTarget.Bounds; + var isWithinBounds = position.X >= 0 && position.X <= bounds.Width && + position.Y >= 0 && position.Y <= bounds.Height; + + var text = e.DataTransfer.TryGetText(); + if (text != null) + { + DropTargetText.Text = isWithinBounds + ? $"Dropped: {text} at ({position.X:F0}, {position.Y:F0})" + : $"ERROR: Position out of bounds! ({position.X:F0}, {position.Y:F0})"; + DragDropStatus.Text = isWithinBounds ? "Drop OK" : "Drop position ERROR"; + } + + e.DragEffects = DragDropEffects.Copy; + } + + private static bool IsChildOf(Visual? child, Visual? parent) + { + if (child == null || parent == null) + return false; + + var current = child.Parent as Visual; + while (current != null) + { + if (current == parent) + return true; + current = current.Parent as Visual; + } + return false; + } + + private void ResetDragDrop_Click(object? sender, RoutedEventArgs e) + { + DropPosition.Text = string.Empty; + DragDropStatus.Text = string.Empty; + DropTargetText.Text = "Drop items here"; + } +} diff --git a/tests/Avalonia.IntegrationTests.Appium/DragDropTests.cs b/tests/Avalonia.IntegrationTests.Appium/DragDropTests.cs new file mode 100644 index 0000000000..4ed7adca28 --- /dev/null +++ b/tests/Avalonia.IntegrationTests.Appium/DragDropTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Threading; +using OpenQA.Selenium.Appium; +using OpenQA.Selenium.Interactions; +using Xunit; + +namespace Avalonia.IntegrationTests.Appium; + +[Collection("Default")] +public class DragDropTests : TestBase +{ + public DragDropTests(DefaultAppFixture fixture) + : base(fixture, "DragDrop") + { + var reset = Session.FindElementByAccessibilityId("ResetDragDrop"); + reset.Click(); + } + + [PlatformFact(TestPlatforms.MacOS)] + public void DragDrop_Coordinates_Correct_When_Controls_Offset_From_Origin() + { + // This test verifies the fix for drag-drop coordinate calculation when + // controls are positioned away from the window origin. + // Issue: In embedded views or when controls have margin/offset from origin, + // the drag-drop coordinates were incorrectly calculated relative to the + // window rather than the view. + + var dragSource = Session.FindElementByAccessibilityId("DragSource"); + var dropTarget = Session.FindElementByAccessibilityId("DropTarget"); + var dropPosition = Session.FindElementByAccessibilityId("DropPosition"); + var dragDropStatus = Session.FindElementByAccessibilityId("DragDropStatus"); + + // Perform drag from source to target + new Actions(Session) + .MoveToElement(dragSource) + .ClickAndHold() + .MoveToElement(dropTarget) + .Release() + .Perform(); + + Thread.Sleep(500); // Allow UI to update + + // Verify the drop was successful + var status = dragDropStatus.Text; + Assert.True(status == "Drop OK" || status == "Copied", + $"Expected drop to succeed, but status was: {status}"); + + // Verify the drop position is within the target bounds + // If the coordinate calculation bug exists, the position would be + // offset by the spacer/margin and would show negative coordinates + // or coordinates outside the target bounds + var positionText = dropPosition.Text; + Assert.StartsWith("Drop:", positionText, StringComparison.Ordinal); + + // The DropTargetText should not contain "ERROR" + var dropTargetText = Session.FindElementByAccessibilityId("DropTargetText"); + Assert.DoesNotContain("ERROR", dropTargetText.Text); + } + + [PlatformFact(TestPlatforms.MacOS)] + public void DragDrop_Position_Updates_During_DragOver() + { + // Verifies that position is correctly reported during drag-over events + var dragSource = Session.FindElementByAccessibilityId("DragSource"); + var dropTarget = Session.FindElementByAccessibilityId("DropTarget"); + var dropPosition = Session.FindElementByAccessibilityId("DropPosition"); + + // Start drag and move over target, then release + var device = new PointerInputDevice(PointerKind.Mouse); + var builder = new ActionBuilder(); + + // Move to drag source and start drag + builder.AddAction(device.CreatePointerMove(dragSource, 0, 0, TimeSpan.FromMilliseconds(100))); + builder.AddAction(device.CreatePointerDown(MouseButton.Left)); + + // Move to drop target (this triggers DragOver events) + builder.AddAction(device.CreatePointerMove(dropTarget, 0, 0, TimeSpan.FromMilliseconds(200))); + + // Pause to allow DragOver events to be processed + builder.AddAction(device.CreatePause(TimeSpan.FromMilliseconds(200))); + + // Release at current position (completes the drag) + builder.AddAction(device.CreatePointerUp(MouseButton.Left)); + + Session.PerformActions(builder.ToActionSequenceList()); + + Thread.Sleep(200); + + // Check that position was recorded (either DragOver or Drop) + var positionText = dropPosition.Text; + + // Position should have been updated during drag-over or drop + Assert.True(positionText.Contains("DragOver:") || positionText.Contains("Drop:"), + $"Expected position to be updated during drag, but got: {positionText}"); + } + + [Fact] + public void DragDrop_Can_Be_Cancelled() + { + var dragSource = Session.FindElementByAccessibilityId("DragSource"); + var dragDropStatus = Session.FindElementByAccessibilityId("DragDropStatus"); + + // Start drag but release outside any drop target + new Actions(Session) + .MoveToElement(dragSource) + .ClickAndHold() + .MoveByOffset(-200, 0) // Move away from drop target + .Release() + .Perform(); + + Thread.Sleep(500); + + // Status should indicate cancelled + var status = dragDropStatus.Text; + Assert.Equal("Cancelled", status); + } +}