Browse Source

Fix macOS thick titlebar mouse event duplication (#19447)

* Added failing test for OSXThickTitleBar single title area click produce double click #19320

* Fixed OSXThickTitleBar title area click duplication and event delays until the event-tracking-loop is completed

* IntegrationTestApp. Move event counter controls to a separate column. Fixes Changing_Size_Should_Not_Change_Position test

* Move pointer tests to Default group to avoid interference

* Try to fix CI crash

* Try disabling test

* Fix CI test. Collection back to Default

* CI fix. Return back empty test.

* CI fix. Minimal bug test

* CI test. Add double click test

* CI fix. Remove double click test.

---------

Co-authored-by: Julien Lebosquain <[email protected]>
Vladislav Pozdniakov 2 months ago
parent
commit
4f666f0ba1

+ 38 - 60
native/Avalonia.Native/src/OSX/AvnWindow.mm

@@ -35,6 +35,7 @@
     bool _canBecomeKeyWindow;
     bool _isExtended;
     bool _isTransitioningToFullScreen;
+    bool _isTitlebarSession;
     AvnMenu* _menu;
     IAvnAutomationPeer* _automationPeer;
     AvnAutomationNode* _automationNode;
@@ -501,68 +502,10 @@
     return NO;
 }
 
-- (void)forwardToAvnView:(NSEvent *)event
-{
-    auto parent = _parent.tryGetWithCast<WindowImpl>();
-    if (!parent) {
-        return;
-    }
-    
-    switch(event.type) {
-        case NSEventTypeLeftMouseDown:
-            [parent->View mouseDown:event];
-            break;
-        case NSEventTypeLeftMouseUp:
-            [parent->View mouseUp:event];
-            break;
-        case NSEventTypeLeftMouseDragged:
-            [parent->View mouseDragged:event];
-            break;
-        case NSEventTypeRightMouseDown:
-            [parent->View rightMouseDown:event];
-            break;
-        case NSEventTypeRightMouseUp:
-            [parent->View rightMouseUp:event];
-            break;
-        case NSEventTypeRightMouseDragged:
-            [parent->View rightMouseDragged:event];
-            break;
-        case NSEventTypeOtherMouseDown:
-            [parent->View otherMouseDown:event];
-            break;
-        case NSEventTypeOtherMouseUp:
-            [parent->View otherMouseUp:event];
-            break;
-        case NSEventTypeOtherMouseDragged:
-            [parent->View otherMouseDragged:event];
-            break;
-        case NSEventTypeMouseMoved:
-            [parent->View mouseMoved:event];
-            break;
-        default:
-            break;
-    }
-}
-
 - (void)sendEvent:(NSEvent *_Nonnull)event
 {
-    // Event-tracking loop for thick titlebar mouse events
-    if (event.type == NSEventTypeLeftMouseDown && [self isPointInTitlebar:event.locationInWindow])
-    {
-        NSEventMask mask = NSEventMaskLeftMouseDragged | NSEventMaskLeftMouseUp;
-        NSEvent *ev = event;
-        while (ev.type != NSEventTypeLeftMouseUp)
-        {
-            [self forwardToAvnView:ev];
-            [super sendEvent:ev]; 
-            ev = [NSApp nextEventMatchingMask:mask
-                                     untilDate:[NSDate distantFuture]
-                                        inMode:NSEventTrackingRunLoopMode
-                                       dequeue:YES];
-        }
-        [self forwardToAvnView:ev];
-        [super sendEvent:ev];
-        return;
+    if (event.type == NSEventTypeLeftMouseDown) {
+        _isTitlebarSession = [self isPointInTitlebar:event.locationInWindow];
     }
     
     [super sendEvent:event];
@@ -603,6 +546,37 @@
             }
             break;
 
+            case NSEventTypeLeftMouseDragged:
+            case NSEventTypeMouseMoved:
+            case NSEventTypeLeftMouseUp:
+            {
+                // Usually NSToolbar events are passed natively to AvnView when the mouse is inside the control.
+                // When a drag operation started in NSToolbar leaves the control region, the view does not get any 
+                // events. We will detect this scenario and pass events ourselves. 
+                
+                if(!_isTitlebarSession || [self isPointInTitlebar:event.locationInWindow]) 
+                    break;
+
+                AvnView* view = parent->View;
+                
+                if(!view) 
+                    break;
+                
+                if(event.type == NSEventTypeLeftMouseDragged)
+                {
+                    [view mouseDragged:event];
+                }
+                else if(event.type == NSEventTypeMouseMoved)
+                {
+                    [view mouseMoved:event];
+                }
+                else if(event.type == NSEventTypeLeftMouseUp)
+                {
+                    [view mouseUp:event];
+                }
+            }
+            break;
+
             case NSEventTypeMouseEntered:
             {
                 parent->UpdateCursor();
@@ -618,6 +592,10 @@
             default:
                 break;
         }
+        
+        if(event.type == NSEventTypeLeftMouseUp) {
+            _isTitlebarSession = NO;
+        }
     }
 }
 

+ 12 - 6
samples/IntegrationTestApp/ShowWindowTest.axaml

@@ -15,7 +15,7 @@
     </Grid>
     
     <integrationTestApp:MeasureBorder Name="MyBorder" Background="{DynamicResource SystemRegionBrush}">
-    <Grid ColumnDefinitions="Auto,Auto" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
+    <Grid ColumnDefinitions="Auto,Auto,50,Auto,Auto" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
       <Label Grid.Column="0" Grid.Row="1">Client Size</Label>
       <TextBox Name="CurrentClientSize" Grid.Column="1" Grid.Row="1" IsReadOnly="True"
                Text="{Binding ClientSize, Mode=OneWay}" />
@@ -62,13 +62,19 @@
       <Label Grid.Row="11" Content="MeasuredWith:" />
       <TextBlock Grid.Column="1" Grid.Row="11" Name="CurrentMeasuredWithText" Text="{Binding #MyBorder.MeasuredWith}" />
 
-      <Label Grid.Column="0" Grid.Row="12">Mouse Move Event Count</Label>
-      <TextBox Name="MouseMoveCount" Grid.Column="1" Grid.Row="12" IsReadOnly="True" Text="0" />
+      <Label Grid.Column="3" Grid.Row="1">Mouse Move Event Count</Label>
+      <TextBox Name="MouseMoveCount" Grid.Column="4" Grid.Row="1" IsReadOnly="True" Text="0" />
       
-      <Label Grid.Column="0" Grid.Row="13">Mouse Release Event Count</Label>
-      <TextBox Name="MouseReleaseCount" Grid.Column="1" Grid.Row="13" IsReadOnly="True" Text="0" />
+      <Label Grid.Column="3" Grid.Row="2">Mouse Down Event Count</Label>
+      <TextBox Name="MouseDownCount" Grid.Column="4" Grid.Row="2" IsReadOnly="True" Text="0" />
       
-      <StackPanel Orientation="Horizontal" Grid.Row="14" Grid.ColumnSpan="2">
+      <Label Grid.Column="3" Grid.Row="3">Mouse Release Event Count</Label>
+      <TextBox Name="MouseReleaseCount" Grid.Column="4" Grid.Row="3" IsReadOnly="True" Text="0" />
+      
+      <Label Grid.Column="3" Grid.Row="4">Double-Click Event Count</Label>
+      <TextBox Name="DoubleClickCount" Grid.Column="4" Grid.Row="4" IsReadOnly="True" Text="0" />
+      
+      <StackPanel Orientation="Horizontal" Grid.Row="12" Grid.ColumnSpan="5">
         <Button Name="HideButton" Command="{Binding $parent[Window].Hide}">Hide</Button>
         <Button Name="AddToWidth" Click="AddToWidth_Click">Add to Width</Button>
         <Button Name="AddToHeight" Click="AddToHeight_Click">Add to Height</Button>

+ 27 - 0
samples/IntegrationTestApp/ShowWindowTest.axaml.cs

@@ -2,6 +2,7 @@ using System;
 using System.Runtime.InteropServices;
 using Avalonia;
 using Avalonia.Controls;
+using Avalonia.Input;
 using Avalonia.Interactivity;
 using Avalonia.Threading;
 
@@ -32,6 +33,8 @@ namespace IntegrationTestApp
         private readonly TextBox? _orderTextBox;
         private int _mouseMoveCount;
         private int _mouseReleaseCount;
+        private int _doubleClickCount;
+        private int _mouseDownCount;
 
         public ShowWindowTest()
         {
@@ -40,8 +43,10 @@ namespace IntegrationTestApp
             PositionChanged += (s, e) => CurrentPosition.Text = $"{Position}";
 
             PointerMoved += OnPointerMoved;
+            PointerPressed += OnPointerPressed;
             PointerReleased += OnPointerReleased;
             PointerExited += (_, e) => ResetCounters();
+            DoubleTapped += OnDoubleTapped;
 
             if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
             {
@@ -87,6 +92,12 @@ namespace IntegrationTestApp
             UpdateCounterDisplays();
         }
         
+        private void OnPointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e)
+        {
+            _mouseDownCount++;
+            UpdateCounterDisplays();
+        }
+        
         private void OnPointerReleased(object? sender, Avalonia.Input.PointerReleasedEventArgs e)
         {
             _mouseReleaseCount++;
@@ -97,19 +108,35 @@ namespace IntegrationTestApp
         {
             _mouseMoveCount = 0;
             _mouseReleaseCount = 0;
+            _doubleClickCount = 0;
+            _mouseDownCount = 0;
+            UpdateCounterDisplays();
+        }
+        
+        private void OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
+        {
+            _doubleClickCount++;
             UpdateCounterDisplays();
         }
         
         private void UpdateCounterDisplays()
         {
             var mouseMoveCountTextBox = this.FindControl<TextBox>("MouseMoveCount");
+            var mouseDownCountTextBox = this.FindControl<TextBox>("MouseDownCount");
             var mouseReleaseCountTextBox = this.FindControl<TextBox>("MouseReleaseCount");
+            var doubleClickCountTextBox = this.FindControl<TextBox>("DoubleClickCount");
             
             if (mouseMoveCountTextBox != null)
                 mouseMoveCountTextBox.Text = _mouseMoveCount.ToString();
             
+            if (mouseDownCountTextBox != null)
+                mouseDownCountTextBox.Text = _mouseDownCount.ToString();
+            
             if (mouseReleaseCountTextBox != null)
                 mouseReleaseCountTextBox.Text = _mouseReleaseCount.ToString();
+                
+            if (doubleClickCountTextBox != null)
+                doubleClickCountTextBox.Text = _doubleClickCount.ToString();
         }
         
         public void ShowTitleAreaControl()

+ 5 - 0
tests/Avalonia.IntegrationTests.Appium/ElementExtensions.cs

@@ -239,6 +239,11 @@ namespace Avalonia.IntegrationTests.Appium
             // does. On Windows, Click() seems to fail with the WindowState checkbox for some reason.
             new Actions(element.WrappedDriver).MoveToElement(element).Click().Perform();
         }
+        
+        public static void SendDoubleClick(this AppiumWebElement element)
+        {
+            new Actions(element.WrappedDriver).MoveToElement(element).DoubleClick().Perform();
+        }
 
         public static void MovePointerOver(this AppiumWebElement element)
         {

+ 52 - 1
tests/Avalonia.IntegrationTests.Appium/PointerTests_MacOS.cs

@@ -6,7 +6,7 @@ using Xunit;
 
 namespace Avalonia.IntegrationTests.Appium;
 
-[Collection("WindowDecorations")]
+[Collection("Default")]
 public class PointerTests_MacOS : TestBase, IDisposable
 {
     public PointerTests_MacOS(DefaultAppFixture fixture)
@@ -42,6 +42,45 @@ public class PointerTests_MacOS : TestBase, IDisposable
         
         secondaryWindow.FindElementByAccessibilityId("_XCUI:CloseWindow").Click();
     }
+
+    [PlatformFact(TestPlatforms.MacOS)]
+    public void OSXThickTitleBar_Single_Click_Does_Not_Generate_DoubleTapped_Event()
+    {
+        SetParameters(true, false, true, true, true);
+        
+        var showNewWindowDecorations = Session.FindElementByAccessibilityId("ShowNewWindowDecorations");
+        showNewWindowDecorations.Click();
+        
+        Thread.Sleep(1000);
+        
+        var secondaryWindow = Session.GetWindowById("SecondaryWindow");
+        var titleAreaControl = secondaryWindow.FindElementByAccessibilityId("TitleAreaControl");
+        Assert.NotNull(titleAreaControl);
+        
+        // Verify initial state - counters should be 0
+        var initialDoubleClickCount = GetDoubleClickCount(secondaryWindow);
+        var initialReleaseCount = GetReleaseCount(secondaryWindow);
+        var initialMouseDownCount = GetMouseDownCount(secondaryWindow);
+        Assert.Equal(0, initialDoubleClickCount);
+        Assert.Equal(0, initialReleaseCount);
+        Assert.Equal(0, initialMouseDownCount);
+        
+        // Perform a single click in titlebar area 
+        secondaryWindow.MovePointerOver();
+        titleAreaControl.MovePointerOver();
+        titleAreaControl.SendClick();
+        Thread.Sleep(800);
+        
+        // After first single click - mouse down = 1, release = 1, double-click = 0
+        var afterFirstClickMouseDownCount = GetMouseDownCount(secondaryWindow);
+        var afterFirstClickReleaseCount = GetReleaseCount(secondaryWindow);
+        var afterFirstClickDoubleClickCount = GetDoubleClickCount(secondaryWindow);
+        Assert.Equal(1, afterFirstClickMouseDownCount);
+        Assert.Equal(1, afterFirstClickReleaseCount);
+        Assert.Equal(0, afterFirstClickDoubleClickCount);
+        
+        secondaryWindow.FindElementByAccessibilityId("_XCUI:CloseWindow").Click();
+    }
     
     private void SetParameters(
         bool extendClientArea,
@@ -79,6 +118,18 @@ public class PointerTests_MacOS : TestBase, IDisposable
         var mouseReleaseCountTextBox = window.FindElementByAccessibilityId("MouseReleaseCount");
         return int.Parse(mouseReleaseCountTextBox.Text ?? "0");
     }
+    
+    private int GetMouseDownCount(AppiumWebElement window)
+    {
+        var mouseDownCountTextBox = window.FindElementByAccessibilityId("MouseDownCount");
+        return int.Parse(mouseDownCountTextBox.Text ?? "0");
+    }
+    
+    private int GetDoubleClickCount(AppiumWebElement window)
+    {
+        var doubleClickCountTextBox = window.FindElementByAccessibilityId("DoubleClickCount");
+        return int.Parse(doubleClickCountTextBox.Text ?? "0");
+    }
 
     public void Dispose()
     {