diff --git a/native/Avalonia.Native/src/OSX/AvnWindow.mm b/native/Avalonia.Native/src/OSX/AvnWindow.mm index 0bd4b0f462..03daa2f296 100644 --- a/native/Avalonia.Native/src/OSX/AvnWindow.mm +++ b/native/Avalonia.Native/src/OSX/AvnWindow.mm @@ -476,8 +476,95 @@ } } +- (BOOL)isPointInTitlebar:(NSPoint)windowPoint +{ + auto parent = _parent.tryGetWithCast(); + if (!parent || !_isExtended) { + return NO; + } + + AvnView* view = parent->View; + NSPoint viewPoint = [view convertPoint:windowPoint fromView:nil]; + double titlebarHeight = [self getExtendedTitleBarHeight]; + + // Check if click is in titlebar area (top portion of view) + if (viewPoint.y <= titlebarHeight) { + // Verify we're actually in a toolbar-related area + NSView* hitView = [[self findRootView:view] hitTest:windowPoint]; + if (hitView) { + NSString* hitViewClass = [hitView className]; + if ([hitViewClass containsString:@"Toolbar"] || [hitViewClass containsString:@"Titlebar"]) { + return YES; + } + } + } + return NO; +} + +- (void)forwardToAvnView:(NSEvent *)event +{ + auto parent = _parent.tryGetWithCast(); + 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; + } + [super sendEvent:event]; auto parent = _parent.tryGetWithCast(); diff --git a/samples/IntegrationTestApp/Pages/WindowDecorationsPage.axaml b/samples/IntegrationTestApp/Pages/WindowDecorationsPage.axaml index 21a5b1d883..d6b418952a 100644 --- a/samples/IntegrationTestApp/Pages/WindowDecorationsPage.axaml +++ b/samples/IntegrationTestApp/Pages/WindowDecorationsPage.axaml @@ -9,6 +9,7 @@ + - + + diff --git a/samples/IntegrationTestApp/ShowWindowTest.axaml.cs b/samples/IntegrationTestApp/ShowWindowTest.axaml.cs index 6dfc3825a0..8e33ff3419 100644 --- a/samples/IntegrationTestApp/ShowWindowTest.axaml.cs +++ b/samples/IntegrationTestApp/ShowWindowTest.axaml.cs @@ -30,6 +30,8 @@ namespace IntegrationTestApp { private readonly DispatcherTimer? _timer; private readonly TextBox? _orderTextBox; + private int _mouseMoveCount; + private int _mouseReleaseCount; public ShowWindowTest() { @@ -37,6 +39,10 @@ namespace IntegrationTestApp DataContext = this; PositionChanged += (s, e) => CurrentPosition.Text = $"{Position}"; + PointerMoved += OnPointerMoved; + PointerReleased += OnPointerReleased; + PointerExited += (_, e) => ResetCounters(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { _orderTextBox = CurrentOrder; @@ -74,5 +80,47 @@ namespace IntegrationTestApp private void AddToWidth_Click(object? sender, RoutedEventArgs e) => Width = Bounds.Width + 10; private void AddToHeight_Click(object? sender, RoutedEventArgs e) => Height = Bounds.Height + 10; + + private void OnPointerMoved(object? sender, Avalonia.Input.PointerEventArgs e) + { + _mouseMoveCount++; + UpdateCounterDisplays(); + } + + private void OnPointerReleased(object? sender, Avalonia.Input.PointerReleasedEventArgs e) + { + _mouseReleaseCount++; + UpdateCounterDisplays(); + } + + public void ResetCounters() + { + _mouseMoveCount = 0; + _mouseReleaseCount = 0; + UpdateCounterDisplays(); + } + + private void UpdateCounterDisplays() + { + var mouseMoveCountTextBox = this.FindControl("MouseMoveCount"); + var mouseReleaseCountTextBox = this.FindControl("MouseReleaseCount"); + + if (mouseMoveCountTextBox != null) + mouseMoveCountTextBox.Text = _mouseMoveCount.ToString(); + + if (mouseReleaseCountTextBox != null) + mouseReleaseCountTextBox.Text = _mouseReleaseCount.ToString(); + } + + public void ShowTitleAreaControl() + { + var titleAreaControl = this.FindControl("TitleAreaControl"); + if (titleAreaControl == null) return; + titleAreaControl.IsVisible = true; + + var titleBarHeight = ExtendClientAreaTitleBarHeightHint > 0 ? ExtendClientAreaTitleBarHeightHint : 30; + titleAreaControl.Margin = new Thickness(110, -titleBarHeight, 8, 0); + titleAreaControl.Height = titleBarHeight; + } } } diff --git a/tests/Avalonia.IntegrationTests.Appium/PointerTests_MacOS.cs b/tests/Avalonia.IntegrationTests.Appium/PointerTests_MacOS.cs new file mode 100644 index 0000000000..c080c0e601 --- /dev/null +++ b/tests/Avalonia.IntegrationTests.Appium/PointerTests_MacOS.cs @@ -0,0 +1,89 @@ +using System; +using System.Threading; +using OpenQA.Selenium.Appium; +using OpenQA.Selenium.Interactions; +using Xunit; + +namespace Avalonia.IntegrationTests.Appium; + +[Collection("WindowDecorations")] +public class PointerTests_MacOS : TestBase, IDisposable +{ + public PointerTests_MacOS(DefaultAppFixture fixture) + : base(fixture, "Window Decorations") + { + } + + [PlatformFact(TestPlatforms.MacOS)] + public void OSXThickTitleBar_Pointer_Events_Continue_Outside_Window_During_Drag() + { + // issue #15696 + 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); + + new Actions(Session).MoveToElement(secondaryWindow).Perform(); + new Actions(Session).MoveToElement(titleAreaControl).Perform(); + new Actions(Session).DragAndDropToOffset(titleAreaControl, 50, -100).Perform(); + + var finalMoveCount = GetMoveCount(secondaryWindow); + var finalReleaseCount = GetReleaseCount(secondaryWindow); + + Assert.True(finalMoveCount >= 10, $"Expected at least 10 new mouse move events outside window, got {finalMoveCount})"); + Assert.Equal(1, finalReleaseCount); + + secondaryWindow.FindElementByAccessibilityId("_XCUI:CloseWindow").Click(); + } + + private void SetParameters( + bool extendClientArea, + bool forceSystemChrome, + bool preferSystemChrome, + bool macOsThickSystemChrome, + bool showTitleAreaControl) + { + var extendClientAreaCheckBox = Session.FindElementByAccessibilityId("WindowExtendClientAreaToDecorationsHint"); + var forceSystemChromeCheckBox = Session.FindElementByAccessibilityId("WindowForceSystemChrome"); + var preferSystemChromeCheckBox = Session.FindElementByAccessibilityId("WindowPreferSystemChrome"); + var macOsThickSystemChromeCheckBox = Session.FindElementByAccessibilityId("WindowMacThickSystemChrome"); + var showTitleAreaControlCheckBox = Session.FindElementByAccessibilityId("WindowShowTitleAreaControl"); + + if (extendClientAreaCheckBox.GetIsChecked() != extendClientArea) + extendClientAreaCheckBox.Click(); + if (forceSystemChromeCheckBox.GetIsChecked() != forceSystemChrome) + forceSystemChromeCheckBox.Click(); + if (preferSystemChromeCheckBox.GetIsChecked() != preferSystemChrome) + preferSystemChromeCheckBox.Click(); + if (macOsThickSystemChromeCheckBox.GetIsChecked() != macOsThickSystemChrome) + macOsThickSystemChromeCheckBox.Click(); + if (showTitleAreaControlCheckBox.GetIsChecked() != showTitleAreaControl) + showTitleAreaControlCheckBox.Click(); + } + + private int GetMoveCount(AppiumWebElement window) + { + var mouseMoveCountTextBox = window.FindElementByAccessibilityId("MouseMoveCount"); + return int.Parse(mouseMoveCountTextBox.Text ?? "0"); + } + + private int GetReleaseCount(AppiumWebElement window) + { + var mouseReleaseCountTextBox = window.FindElementByAccessibilityId("MouseReleaseCount"); + return int.Parse(mouseReleaseCountTextBox.Text ?? "0"); + } + + public void Dispose() + { + SetParameters(false, false, false, false, false); + var applyButton = Session.FindElementByAccessibilityId("ApplyWindowDecorations"); + applyButton.Click(); + } +}