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 22e4dee0d..acc0f7776 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBar.cs @@ -674,6 +674,134 @@ or PInvoke.WM_NCLBUTTONUP return IntPtr.Zero; } + 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) + { + if (TrailingContent is UIElement || Header is UIElement || CenterContent is UIElement) + { + 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)); + } + + TitleBarButton? rightmostButton = null; + double rightmostRightEdge = double.MinValue; + + foreach (TitleBarButton button in _buttons) + { + if (button is null) + { + continue; + } + + try + { + if (PresentationSource.FromVisual(button) is not null) + { + Point buttonTopLeft = button.PointToScreen(new Point(0, 0)); + double buttonRightEdge = buttonTopLeft.X + button.RenderSize.Width; + + if (buttonRightEdge > rightmostRightEdge) + { + rightmostRightEdge = buttonRightEdge; + rightmostButton = button; + } + } + } + catch + { + // Ignore visual transform errors and keep searching. + } + + if (TitleBarButton.IsMouseOverNonClient(button, lParam)) + { + isMouseOverButtons = true; + } + } + + htResult = GetWindowBorderHitTestResult(hwnd, lParam); + + // If resize hit test succeeds, let Windows handle it + if (htResult != (IntPtr)PInvoke.HTNOWHERE) + { + handled = true; + return htResult; + } + + if (rightmostButton is not null + && Windows.Win32.PInvoke.GetCursorPos(out System.Drawing.Point cursorPoint)) + { + Point cursorPosition = new(cursorPoint.X, cursorPoint.Y); + + try + { + Point rightmostTopLeft = rightmostButton.PointToScreen(new Point(0, 0)); + double rightEdge = rightmostTopLeft.X + rightmostButton.RenderSize.Width; + double leftEdge = rightEdge - 1; + double bottomEdge = rightmostTopLeft.Y + rightmostButton.RenderSize.Height; + + if ( + cursorPosition.X >= leftEdge + && cursorPosition.X <= rightEdge + && cursorPosition.Y >= rightmostTopLeft.Y + && cursorPosition.Y <= bottomEdge + ) + { + 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)) @@ -699,25 +827,6 @@ or PInvoke.WM_NCLBUTTONUP return returnIntPtr; } - bool isMouseOverHeaderContent = false; - IntPtr htResult = (IntPtr)PInvoke.HTNOWHERE; - - if (message == PInvoke.WM_NCHITTEST) - { - if (TrailingContent is UIElement || Header is UIElement || CenterContent is UIElement) - { - UIElement? headerLeftUIElement = Header as UIElement; - UIElement? headerCenterUIElement = CenterContent as UIElement; - UIElement? headerRightUiElement = TrailingContent as UIElement; - - isMouseOverHeaderContent = (headerLeftUIElement is not null && headerLeftUIElement != _titleBlock && headerLeftUIElement.IsMouseOverElement(lParam)) - || (headerCenterUIElement?.IsMouseOverElement(lParam) ?? false) - || (headerRightUiElement?.IsMouseOverElement(lParam) ?? false); - } - - htResult = GetWindowBorderHitTestResult(hwnd, lParam); - } - var e = new HwndProcEventArgs(hwnd, msg, wParam, lParam, isMouseOverHeaderContent); WndProcInvoked?.Invoke(this, e); @@ -729,14 +838,14 @@ or PInvoke.WM_NCLBUTTONUP 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: diff --git a/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs b/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs index 56bfca17c..5cf41c48c 100644 --- a/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs +++ b/src/Wpf.Ui/Controls/TitleBar/TitleBarButton.cs @@ -12,6 +12,92 @@ namespace Wpf.Ui.Controls; public class TitleBarButton : Wpf.Ui.Controls.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; + } + + var 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 (!Windows.Win32.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: diff --git a/src/Wpf.Ui/Interop/PInvoke.cs b/src/Wpf.Ui/Interop/PInvoke.cs index 1d4c052c5..b0a21376b 100644 --- a/src/Wpf.Ui/Interop/PInvoke.cs +++ b/src/Wpf.Ui/Interop/PInvoke.cs @@ -4,7 +4,6 @@ // All Rights Reserved. using System.Runtime.InteropServices; -using Windows.Win32.Foundation; using Windows.Win32.UI.WindowsAndMessaging; namespace Windows.Win32; @@ -15,5 +14,5 @@ internal static partial class PInvoke DllImport("USER32.dll", ExactSpelling = true, EntryPoint = "SetWindowLongPtrW", SetLastError = true), DefaultDllImportSearchPaths(DllImportSearchPath.System32) ] - internal static extern nint SetWindowLongPtr(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, nint dwNewLong); + internal static extern nint SetWindowLongPtr(Windows.Win32.Foundation.HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, nint dwNewLong); } diff --git a/src/Wpf.Ui/NativeMethods.txt b/src/Wpf.Ui/NativeMethods.txt index fe5b8a161..15aff33b4 100644 --- a/src/Wpf.Ui/NativeMethods.txt +++ b/src/Wpf.Ui/NativeMethods.txt @@ -3,6 +3,7 @@ DwmIsCompositionEnabled DwmExtendFrameIntoClientArea SetWindowThemeAttribute GetDpiForWindow +GetCursorPos GetForegroundWindow IsWindowVisible SetWindowLong