diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs index bb6b19a49..a47a2eda3 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBar.WindowResize.cs @@ -35,6 +35,24 @@ namespace Wpf.Ui.Controls; /// public partial class TitleBar { + /// + /// Bit flags that represent which window border edges the cursor is currently over. + /// + [Flags] + private enum BorderHitEdges : uint + { + /// No border edge is hit. + None = 0, + /// The left border edge is hit. + Left = 1 << 0, + /// The right border edge is hit. + Right = 1 << 1, + /// The top border edge is hit. + Top = 1 << 2, + /// The bottom border edge is hit. + Bottom = 1 << 3, + } + private int _borderX; private int _borderY; @@ -60,29 +78,36 @@ private IntPtr GetWindowBorderHitTestResult(IntPtr hwnd, IntPtr lParam) int x = (short)(lp & 0xFFFF); int y = (short)((lp >> 16) & 0xFFFF); - uint hit = 0u; + BorderHitEdges hit = BorderHitEdges.None; -#pragma warning disable if (x < windowRect.left + _borderX) - hit |= 0b0001u; // left + hit |= BorderHitEdges.Left; if (x >= windowRect.right - _borderX) - hit |= 0b0010u; // right + hit |= BorderHitEdges.Right; if (y < windowRect.top + _borderY) - hit |= 0b0100u; // top + hit |= BorderHitEdges.Top; if (y >= windowRect.bottom - _borderY) - hit |= 0b1000u; // bottom -#pragma warning restore + hit |= BorderHitEdges.Bottom; + + if (hit == (BorderHitEdges.Top | BorderHitEdges.Right)) + { + const int cornerWidth = 1; + if (x < windowRect.right - cornerWidth) + { + hit = BorderHitEdges.Top; + } + } return hit switch { - 0b0101u => (IntPtr)PInvoke.HTTOPLEFT, // top + left (0b0100 | 0b0001) - 0b0110u => (IntPtr)PInvoke.HTTOPRIGHT, // top + right (0b0100 | 0b0010) - 0b1001u => (IntPtr)PInvoke.HTBOTTOMLEFT, // bottom + left (0b1000 | 0b0001) - 0b1010u => (IntPtr)PInvoke.HTBOTTOMRIGHT, // bottom + right (0b1000 | 0b0010) - 0b0100u => (IntPtr)PInvoke.HTTOP, // top - 0b0001u => (IntPtr)PInvoke.HTLEFT, // left - 0b1000u => (IntPtr)PInvoke.HTBOTTOM, // bottom - 0b0010u => (IntPtr)PInvoke.HTRIGHT, // right + BorderHitEdges.Top | BorderHitEdges.Left => (IntPtr)PInvoke.HTTOPLEFT, + BorderHitEdges.Top | BorderHitEdges.Right => (IntPtr)PInvoke.HTTOPRIGHT, + BorderHitEdges.Bottom | BorderHitEdges.Left => (IntPtr)PInvoke.HTBOTTOMLEFT, + BorderHitEdges.Bottom | BorderHitEdges.Right => (IntPtr)PInvoke.HTBOTTOMRIGHT, + BorderHitEdges.Top => (IntPtr)PInvoke.HTTOP, + BorderHitEdges.Left => (IntPtr)PInvoke.HTLEFT, + BorderHitEdges.Bottom => (IntPtr)PInvoke.HTBOTTOM, + BorderHitEdges.Right => (IntPtr)PInvoke.HTRIGHT, // no match = HTNOWHERE (stop processing) _ => (IntPtr)PInvoke.HTNOWHERE, diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs index e52900913..4c22d6dfa 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs @@ -408,16 +408,16 @@ public event TypedEventHandler HelpClicked /// /// Gets or sets the that should be executed when the Maximize button is clicked."/> /// - public Action? MaximizeActionOverride { get; set; } + public Action? MaximizeActionOverride { get; set; } /// /// Gets or sets what should be executed when the Minimize button is clicked. /// - public Action? MinimizeActionOverride { get; set; } + public Action? MinimizeActionOverride { get; set; } private readonly TitleBarButton?[] _buttons = new TitleBarButton[4]; private readonly TextBlock _titleBlock; - private System.Windows.Window _currentWindow = null!; + private Window _currentWindow = null!; /*private System.Windows.Controls.Grid _mainGrid = null!;*/ private System.Windows.Controls.ContentPresenter _icon = null!; @@ -468,7 +468,7 @@ protected virtual void OnLoaded(object sender, RoutedEventArgs e) } _currentWindow = - System.Windows.Window.GetWindow(this) ?? throw new InvalidOperationException("Window is null"); + Window.GetWindow(this) ?? throw new InvalidOperationException("Window is null"); if (_currentWindow.WindowState == WindowState.Maximized) { SetCurrentValue(IsMaximizedProperty, true); @@ -674,54 +674,160 @@ or PInvoke.WM_NCLBUTTONUP return IntPtr.Zero; } - foreach (TitleBarButton? button in _buttons) + bool isMouseOverHeaderContent = false; + bool isMouseOverButtons = false; + IntPtr htResult = (IntPtr)PInvoke.HTNOWHERE; + + // For WM_NCHITTEST, perform resize detection first, and skip button hit testing if top-left or top-right corner resize detection succeeds + if (message == PInvoke.WM_NCHITTEST) { - // Check if button is null to avoid potential NullReferenceException if OnApplyTemplate hasn't been called yet, e.g. when TitleBar has Visibility == Collapsed. - if (button is null || !button.ReactToHwndHook(message, lParam, out IntPtr returnIntPtr)) + if (TrailingContent is UIElement || Header is UIElement || CenterContent is UIElement) { - continue; + UIElement? headerLeftUIElement = Header as UIElement; + UIElement? headerCenterUIElement = CenterContent as UIElement; + UIElement? headerTrailingUiElement = TrailingContent as UIElement; + + isMouseOverHeaderContent = + (headerLeftUIElement is not null + && headerLeftUIElement != _titleBlock + && TitleBarButton.IsMouseOverNonClient(headerLeftUIElement, lParam)) || (headerCenterUIElement is not null + && TitleBarButton.IsMouseOverNonClient(headerCenterUIElement, lParam)) || (headerTrailingUiElement is not null + && TitleBarButton.IsMouseOverNonClient(headerTrailingUiElement, lParam)); } - // Fix for when sometimes, button hover backgrounds aren't cleared correctly, causing multiple buttons to appear as if hovered. - foreach (TitleBarButton? anotherButton in _buttons) + TitleBarButton? rightmostButton = null; + double rightmostRightEdge = double.MinValue; + + foreach (TitleBarButton button in _buttons) { - if (anotherButton is null || anotherButton == button) + if (button is null) { continue; } - if (anotherButton.IsHovered && button.IsHovered) + try { - anotherButton.RemoveHover(); + if (PresentationSource.FromVisual(button) is not null) + { + double buttonRightEdge = button.PointToScreen(new Point(button.RenderSize.Width, 0)).X; + + if (buttonRightEdge > rightmostRightEdge) + { + rightmostRightEdge = buttonRightEdge; + rightmostButton = button; + } + } + } + catch + { + // Ignore visual transform errors and keep searching. + } + + if (TitleBarButton.IsMouseOverNonClient(button, lParam)) + { + isMouseOverButtons = true; } } - handled = true; - return returnIntPtr; - } + htResult = GetWindowBorderHitTestResult(hwnd, lParam); - bool isMouseOverHeaderContent = false; - IntPtr htResult = (IntPtr)PInvoke.HTNOWHERE; + // Resize zones always take priority over buttons, matching native Windows behavior. + // The resize strip occupies the outermost few pixels of each edge; GetWindowBorderHitTestResult + // operates in physical pixels throughout, so the zone is correctly positioned at any DPI. + if (htResult != (IntPtr)PInvoke.HTNOWHERE) + { + RemoveButtonHovers(); + handled = true; + return htResult; + } - if (message == PInvoke.WM_NCHITTEST) - { - if (TrailingContent is UIElement || Header is UIElement || CenterContent is UIElement) + if (rightmostButton is not null + && PInvoke.GetCursorPos(out System.Drawing.Point cursorPoint)) { - UIElement? headerLeftUIElement = Header as UIElement; - UIElement? headerCenterUIElement = CenterContent as UIElement; - UIElement? headerRightUiElement = TrailingContent as UIElement; + Point cursorPosition = new(cursorPoint.X, cursorPoint.Y); - isMouseOverHeaderContent = - ( - headerLeftUIElement is not null - && headerLeftUIElement != _titleBlock - && headerLeftUIElement.IsMouseOverElement(lParam) + try + { + Point rightmostTopLeft = rightmostButton.PointToScreen(new Point(0, 0)); + double rightEdge = rightmostButton.PointToScreen(new Point(rightmostButton.RenderSize.Width, 0)).X; + double leftEdge = rightEdge - 1; + double bottomEdge = rightmostButton.PointToScreen(new Point(0, rightmostButton.RenderSize.Height)).Y; + + if ( + cursorPosition.X >= leftEdge + && cursorPosition.X <= rightEdge + && cursorPosition.Y >= rightmostTopLeft.Y + && cursorPosition.Y <= bottomEdge ) - || (headerCenterUIElement?.IsMouseOverElement(lParam) ?? false) - || (headerRightUiElement?.IsMouseOverElement(lParam) ?? false); + { + RemoveButtonHovers(); + handled = true; + return (IntPtr)PInvoke.HTRIGHT; + } + } + catch + { + // Ignore transform errors and fall back to default hit testing. + } + } + + if (isMouseOverButtons) + { + htResult = (IntPtr)PInvoke.HTNOWHERE; + } + } + else if (message == PInvoke.WM_NCLBUTTONDOWN) + { + // For WM_NCLBUTTONDOWN, also skip button hit testing if within top-left or top-right corner resize area + // This ensures resize handling works correctly + foreach (TitleBarButton button in _buttons) + { + if (button is null) + { + continue; + } + + if (TitleBarButton.IsMouseOverNonClient(button, lParam)) + { + isMouseOverButtons = true; + break; + } } htResult = GetWindowBorderHitTestResult(hwnd, lParam); + + if (htResult != (IntPtr)PInvoke.HTNOWHERE) + { + // If within resize area, skip button hit testing + // and let Windows handle the default resize processing + handled = false; + return IntPtr.Zero; + } + } + + foreach (TitleBarButton button in _buttons) + { + if (!button.ReactToHwndHook(message, lParam, out IntPtr returnIntPtr)) + { + continue; + } + + // Fix for when sometimes, button hover backgrounds aren't cleared correctly, causing multiple buttons to appear as if hovered. + foreach (TitleBarButton anotherButton in _buttons) + { + if (anotherButton == button) + { + continue; + } + + if (anotherButton.IsHovered && button.IsHovered) + { + anotherButton.RemoveHover(); + } + } + + handled = true; + return returnIntPtr; } var e = new HwndProcEventArgs(hwnd, msg, wParam, lParam, isMouseOverHeaderContent); @@ -735,14 +841,14 @@ headerLeftUIElement is not null switch (message) { - case PInvoke.WM_NCHITTEST when CloseWindowByDoubleClickOnIcon && _icon.IsMouseOverElement(lParam): + case PInvoke.WM_NCHITTEST when CloseWindowByDoubleClickOnIcon && TitleBarButton.IsMouseOverNonClient(_icon, lParam): // Ideally, clicking on the icon should open the system menu, but when the system menu is opened manually, double-clicking on the icon does not close the window handled = true; return (IntPtr)PInvoke.HTSYSMENU; case PInvoke.WM_NCHITTEST when htResult != (IntPtr)PInvoke.HTNOWHERE: handled = true; return htResult; - case PInvoke.WM_NCHITTEST when this.IsMouseOverElement(lParam) && !isMouseOverHeaderContent: + case PInvoke.WM_NCHITTEST when TitleBarButton.IsMouseOverNonClient(this, lParam) && !isMouseOverHeaderContent: handled = true; return (IntPtr)PInvoke.HTCAPTION; default: @@ -750,6 +856,14 @@ headerLeftUIElement is not null } } + private void RemoveButtonHovers() + { + foreach (TitleBarButton button in _buttons) + { + button?.RemoveHover(); + } + } + /// /// Show 'SystemMenu' on mouse right button up. /// diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs index 56bfca17c..ee3828788 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs @@ -10,8 +10,94 @@ // ReSharper disable once CheckNamespace namespace Wpf.Ui.Controls; -public class TitleBarButton : Wpf.Ui.Controls.Button +public class TitleBarButton : Button { + // We intentionally keep this logic local to TitleBar components to avoid changing + // global hit-testing behavior for other controls. + internal static bool IsMouseOverNonClient(UIElement element, IntPtr lParam, double tolerance = 1.0) + { + // This will be invoked very often and must be as simple as possible. + if (lParam == IntPtr.Zero) + { + return false; + } + + try + { + // Ensure the visual is connected to a presentation source (needed for PointFromScreen). + if (PresentationSource.FromVisual(element) == null) + { + return false; + } + + Point mousePosition = TryGetCursorPos(out Point cursorPosition) + ? cursorPosition + : GetLParamPoint(lParam); + + // Add a small tolerance to reduce hover flicker at pixel boundaries (rounding/DPI edge cases). + var hitRect = new Rect( + -tolerance, + -tolerance, + element.RenderSize.Width + (2 * tolerance), + element.RenderSize.Height + (2 * tolerance) + ); + + if (!hitRect.Contains(element.PointFromScreen(mousePosition)) || !element.IsHitTestVisible) + { + return false; + } + + // If element is Panel, check if children at mousePosition is with IsHitTestVisible false. + if (element is System.Windows.Controls.Panel panel) + { + foreach (UIElement child in panel.Children) + { + var childHitRect = new Rect( + -tolerance, + -tolerance, + child.RenderSize.Width + (2 * tolerance), + child.RenderSize.Height + (2 * tolerance) + ); + + if (childHitRect.Contains(child.PointFromScreen(mousePosition))) + { + return child.IsHitTestVisible; + } + } + + return false; + } + + return true; + } + catch + { + return false; + } + } + + private static Point GetLParamPoint(IntPtr lParam) + { + long lp = lParam.ToInt64(); + int x = (short)(lp & 0xFFFF); + int y = (short)(lp >> 16); + + return new Point(x, y); + } + + private static bool TryGetCursorPos(out Point mousePosition) + { + mousePosition = default; + + if (!PInvoke.GetCursorPos(out System.Drawing.Point point)) + { + return false; + } + + mousePosition = new Point(point.X, point.Y); + return true; + } + /// Identifies the dependency property. public static readonly DependencyProperty ButtonTypeProperty = DependencyProperty.Register( nameof(ButtonType), @@ -179,7 +265,7 @@ internal bool ReactToHwndHook(uint msg, IntPtr lParam, out IntPtr returnIntPtr) switch (msg) { case PInvoke.WM_NCHITTEST: - if (this.IsMouseOverElement(lParam)) + if (IsMouseOverNonClient(this, lParam)) { /*Debug.WriteLine($"Hitting {ButtonType} | return code {_returnValue}");*/ Hover(); @@ -192,10 +278,10 @@ internal bool ReactToHwndHook(uint msg, IntPtr lParam, out IntPtr returnIntPtr) case PInvoke.WM_NCMOUSELEAVE: // Mouse leaves the window RemoveHover(); return false; - case PInvoke.WM_NCLBUTTONDOWN when this.IsMouseOverElement(lParam): // Left button clicked down + case PInvoke.WM_NCLBUTTONDOWN when IsMouseOverNonClient(this, lParam): // Left button clicked down _isClickedDown = true; return true; - case PInvoke.WM_NCLBUTTONUP when _isClickedDown && this.IsMouseOverElement(lParam): // Left button clicked up + case PInvoke.WM_NCLBUTTONUP when _isClickedDown && IsMouseOverNonClient(this, lParam): // Left button clicked up InvokeClick(); return true; default: @@ -232,34 +318,4 @@ protected void OnButtonTypeChanged(DependencyPropertyChangedEventArgs e) ), }; } - - // TODO: Incorrectly calculates mouse position for high DPI displays. - // PresentationSource presentationSource = null; - // protected bool IsMouseOverElement(nint lParam) - // { - // System.Drawing.Point winPoint; - // bool gotCursorPos = User32.GetCursorPos(out winPoint); - - // if (!gotCursorPos) - // { - // int fallbackX = unchecked((short)((long)lParam & 0xFFFF)); - // int fallbackY = unchecked((short)(((long)lParam >> 16) & 0xFFFF)); - // winPoint = new System.Drawing.Point(fallbackX, fallbackY); - // } - - // var screenPoint = new System.Windows.Point(winPoint.X, winPoint.Y); - - // presentationSource ??= PresentationSource.FromVisual(this); - - // if (presentationSource?.CompositionTarget != null) - // { - // screenPoint = presentationSource.CompositionTarget.TransformFromDevice.Transform(screenPoint); - // } - - // var localPoint = this.PointFromScreen(screenPoint); - - // var hitTestRect = new System.Windows.Rect(0, 0, this.ActualWidth, this.ActualHeight); - - // return hitTestRect.Contains(localPoint); - //} } diff --git a/src/Wpf.Ui/NativeMethods.txt b/src/Wpf.Ui/NativeMethods.txt index fe5b8a161..ed0384d83 100644 --- a/src/Wpf.Ui/NativeMethods.txt +++ b/src/Wpf.Ui/NativeMethods.txt @@ -2,6 +2,7 @@ S_OK DwmIsCompositionEnabled DwmExtendFrameIntoClientArea SetWindowThemeAttribute +GetCursorPos GetDpiForWindow GetForegroundWindow IsWindowVisible @@ -32,4 +33,4 @@ HTMINBUTTON HTNOWHERE HTRIGHT* HTSYSMENU -HTTOP* \ No newline at end of file +HTTOP*